"""Phase 3 — RL_13–RL_17: ``muse reflog delete`` subcommand. TDD: tests are written before the implementation. Each class maps to one or more deliverables from issue #47, Phase 3. Coverage tiers -------------- RL_13 delete @{N} removes the entry at index N; deleted:1, remaining:N-1 RL_14 delete --all removes all entries; log file deleted; deleted == prior count RL_15 out-of-bounds index exits USER_ERROR with a message naming valid range RL_16 delete is atomic (write-tmp + os.replace); original survives mock-kill RL_17 integration: 3-entry log → delete @{1} → 2 entries remain (newest+oldest) """ from __future__ import annotations import json import pathlib from unittest.mock import patch import pytest from muse.core.paths import muse_dir, logs_dir from muse.core.types import fake_id from tests.cli_test_helper import CliRunner runner = CliRunner() cli = None # argparse migration stub _DAY = 86400 # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _env(root: pathlib.Path) -> dict[str, str]: return {"MUSE_REPO_ROOT": str(root)} def _make_repo(tmp_path: pathlib.Path, branch: str = "main") -> pathlib.Path: """Create a minimal .muse/ structure with no commits.""" from muse._version import __version__ dot = muse_dir(tmp_path) for sub in ( "refs/heads", "objects", "commits", "snapshots", "logs/refs/heads", "logs", ): (dot / sub).mkdir(parents=True, exist_ok=True) (dot / "repo.json").write_text( json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"}) ) (dot / "HEAD").write_text(f"ref: refs/heads/{branch}\n") (dot / "config.toml").write_text("") return tmp_path def _head_log(root: pathlib.Path) -> pathlib.Path: return logs_dir(root) / "HEAD" def _branch_log(root: pathlib.Path, branch: str) -> pathlib.Path: return logs_dir(root) / "refs" / "heads" / branch def _write_entries(log_path: pathlib.Path, operations: list[str]) -> None: """Write one raw reflog line per operation, in chronological order (oldest first). Timestamps are spaced 1 second apart starting at unix epoch + 1000 so ordering is deterministic and all entries are old enough to avoid accidental expiry in other tests. """ import time as _time log_path.parent.mkdir(parents=True, exist_ok=True) base_ts = int(_time.time()) - len(operations) * 2 # all in the recent past lines = [] for i, op in enumerate(operations): old = fake_id(f"old{i}") new = fake_id(f"new{i}") ts = base_ts + i lines.append(f"{old} {new} user {ts} +0000\t{op}\n") log_path.write_text("".join(lines), encoding="utf-8") def _read_operations(root: pathlib.Path, branch: str | None = None) -> list[str]: """Return operation strings newest-first (mirrors read_reflog order).""" from muse.core.reflog import read_reflog return [e.operation for e in read_reflog(root, branch=branch, limit=1000)] # --------------------------------------------------------------------------- # RL_13 — delete @{N} removes the entry at index N # --------------------------------------------------------------------------- class TestDeleteByIndex: """RL_13: delete @{0} removes the newest entry.""" def test_delete_newest_entry(self, tmp_path: pathlib.Path) -> None: root = _make_repo(tmp_path) _write_entries(_head_log(root), ["oldest", "middle", "newest"]) r = runner.invoke(cli, ["reflog", "delete", "@{0}", "--json"], env=_env(root)) assert r.exit_code == 0, r.output data = json.loads(r.output) assert data["deleted"] == 1 assert data["remaining"] == 2 def test_newest_entry_is_gone_after_delete(self, tmp_path: pathlib.Path) -> None: root = _make_repo(tmp_path) _write_entries(_head_log(root), ["oldest", "middle", "newest"]) runner.invoke(cli, ["reflog", "delete", "@{0}"], env=_env(root)) ops = _read_operations(root) assert "newest" not in ops assert "middle" in ops assert "oldest" in ops def test_json_schema_has_required_fields(self, tmp_path: pathlib.Path) -> None: root = _make_repo(tmp_path) _write_entries(_head_log(root), ["a", "b"]) r = runner.invoke(cli, ["reflog", "delete", "@{0}", "--json"], env=_env(root)) data = json.loads(r.output) for key in ("deleted", "remaining", "ref", "exit_code", "duration_ms"): assert key in data, f"missing key {key!r}" def test_ref_field_is_head_by_default(self, tmp_path: pathlib.Path) -> None: root = _make_repo(tmp_path) _write_entries(_head_log(root), ["a"]) r = runner.invoke(cli, ["reflog", "delete", "@{0}", "--json"], env=_env(root)) data = json.loads(r.output) assert data["ref"] == "HEAD" def test_delete_last_entry_removes_log_file(self, tmp_path: pathlib.Path) -> None: """Deleting the only entry must remove the log file.""" root = _make_repo(tmp_path) log = _head_log(root) _write_entries(log, ["only"]) r = runner.invoke(cli, ["reflog", "delete", "@{0}", "--json"], env=_env(root)) assert r.exit_code == 0, r.output assert not log.exists(), "log file must be removed when all entries are deleted" def test_delete_by_branch_flag(self, tmp_path: pathlib.Path) -> None: """--branch targets the branch-specific log, not HEAD.""" root = _make_repo(tmp_path) _write_entries(_branch_log(root, "main"), ["branch-a", "branch-b"]) _write_entries(_head_log(root), ["head-a", "head-b"]) r = runner.invoke( cli, ["reflog", "delete", "@{0}", "--branch", "main", "--json"], env=_env(root), ) assert r.exit_code == 0, r.output data = json.loads(r.output) assert data["deleted"] == 1 assert data["ref"] == "refs/heads/main" # HEAD log must be untouched. assert len(_read_operations(root)) == 2 def test_delete_middle_entry(self, tmp_path: pathlib.Path) -> None: """@{1} removes the second-newest entry.""" root = _make_repo(tmp_path) _write_entries(_head_log(root), ["oldest", "middle", "newest"]) runner.invoke(cli, ["reflog", "delete", "@{1}"], env=_env(root)) ops = _read_operations(root) assert "middle" not in ops assert "newest" in ops assert "oldest" in ops def test_delete_oldest_entry(self, tmp_path: pathlib.Path) -> None: """@{2} in a 3-entry log removes the oldest entry.""" root = _make_repo(tmp_path) _write_entries(_head_log(root), ["oldest", "middle", "newest"]) runner.invoke(cli, ["reflog", "delete", "@{2}"], env=_env(root)) ops = _read_operations(root) assert "oldest" not in ops assert "middle" in ops assert "newest" in ops # --------------------------------------------------------------------------- # RL_14 — delete --all removes all entries; log file is deleted # --------------------------------------------------------------------------- class TestDeleteAll: """RL_14: delete --all wipes the entire log.""" def test_all_flag_removes_every_entry(self, tmp_path: pathlib.Path) -> None: root = _make_repo(tmp_path) log = _head_log(root) _write_entries(log, ["a", "b", "c"]) r = runner.invoke(cli, ["reflog", "delete", "--all", "--json"], env=_env(root)) assert r.exit_code == 0, r.output data = json.loads(r.output) assert data["deleted"] == 3 assert data["remaining"] == 0 def test_all_flag_removes_log_file(self, tmp_path: pathlib.Path) -> None: root = _make_repo(tmp_path) log = _head_log(root) _write_entries(log, ["a", "b"]) runner.invoke(cli, ["reflog", "delete", "--all"], env=_env(root)) assert not log.exists(), "log file must be removed after --all delete" def test_all_on_branch_log(self, tmp_path: pathlib.Path) -> None: root = _make_repo(tmp_path) blog = _branch_log(root, "dev") _write_entries(blog, ["x", "y"]) _write_entries(_head_log(root), ["h"]) runner.invoke(cli, ["reflog", "delete", "--all", "--branch", "dev"], env=_env(root)) assert not blog.exists() # HEAD log must be untouched. assert _head_log(root).exists() def test_all_on_empty_log_is_graceful(self, tmp_path: pathlib.Path) -> None: """--all with no log file must exit 0 and report deleted:0.""" root = _make_repo(tmp_path) r = runner.invoke(cli, ["reflog", "delete", "--all", "--json"], env=_env(root)) assert r.exit_code == 0, r.output data = json.loads(r.output) assert data["deleted"] == 0 assert data["remaining"] == 0 def test_all_and_index_together_rejected(self, tmp_path: pathlib.Path) -> None: """--all and a positional @{N} index together must fail.""" root = _make_repo(tmp_path) r = runner.invoke(cli, ["reflog", "delete", "--all", "@{0}"], env=_env(root)) assert r.exit_code != 0 def test_muse_reflog_returns_empty_after_all_delete(self, tmp_path: pathlib.Path) -> None: root = _make_repo(tmp_path) _write_entries(_head_log(root), ["a", "b"]) runner.invoke(cli, ["reflog", "delete", "--all"], env=_env(root)) r = runner.invoke(cli, ["reflog", "--json"], env=_env(root)) data = json.loads(r.output) assert data["entries"] == [] # --------------------------------------------------------------------------- # RL_15 — out-of-bounds index exits USER_ERROR with valid-range message # --------------------------------------------------------------------------- class TestDeleteOutOfBounds: """RL_15: index beyond valid range → USER_ERROR with range in the message.""" def test_out_of_bounds_exits_user_error(self, tmp_path: pathlib.Path) -> None: root = _make_repo(tmp_path) _write_entries(_head_log(root), ["a", "b", "c"]) # valid: 0–2 r = runner.invoke(cli, ["reflog", "delete", "@{5}"], env=_env(root)) assert r.exit_code == 1 # USER_ERROR def test_out_of_bounds_message_names_valid_range(self, tmp_path: pathlib.Path) -> None: root = _make_repo(tmp_path) _write_entries(_head_log(root), ["a", "b", "c"]) # 3 entries → valid: 0–2 r = runner.invoke(cli, ["reflog", "delete", "@{5}"], env=_env(root)) # Valid range "0" and "2" (or "0–2") must appear somewhere in output combined = r.output + (r.stderr if hasattr(r, "stderr") else "") assert "0" in combined assert "2" in combined def test_out_of_bounds_does_not_modify_log(self, tmp_path: pathlib.Path) -> None: root = _make_repo(tmp_path) log = _head_log(root) _write_entries(log, ["a", "b"]) original = log.read_text() runner.invoke(cli, ["reflog", "delete", "@{99}"], env=_env(root)) assert log.read_text() == original, "log must not be modified on out-of-bounds" def test_no_log_file_gives_user_error(self, tmp_path: pathlib.Path) -> None: """Deleting from a nonexistent log is an error.""" root = _make_repo(tmp_path) r = runner.invoke(cli, ["reflog", "delete", "@{0}"], env=_env(root)) assert r.exit_code == 1 # USER_ERROR — nothing to delete def test_malformed_index_is_rejected(self, tmp_path: pathlib.Path) -> None: """@{abc} is not a valid index — must fail.""" root = _make_repo(tmp_path) _write_entries(_head_log(root), ["a"]) r = runner.invoke(cli, ["reflog", "delete", "@{abc}"], env=_env(root)) assert r.exit_code == 1 # --------------------------------------------------------------------------- # RL_16 — delete is atomic (write-tmp + os.replace) # --------------------------------------------------------------------------- class TestDeleteAtomic: """RL_16: single-entry delete uses write-tmp + os.replace; original survives mock-kill.""" def test_original_intact_when_replace_fails(self, tmp_path: pathlib.Path) -> None: """If os.replace raises, the original log is unchanged.""" root = _make_repo(tmp_path) log = _head_log(root) _write_entries(log, ["oldest", "newest"]) # 2 entries → kept_lines not empty original = log.read_text() with patch("muse.core.reflog.os.replace", side_effect=OSError("disk full")): runner.invoke(cli, ["reflog", "delete", "@{0}"], env=_env(root)) assert log.read_text() == original, "original log must be unchanged when replace fails" def test_no_tmp_files_left_on_success(self, tmp_path: pathlib.Path) -> None: """After a successful delete no .lock / .tmp files remain.""" root = _make_repo(tmp_path) _write_entries(_head_log(root), ["a", "b", "c"]) runner.invoke(cli, ["reflog", "delete", "@{0}"], env=_env(root)) log_dir = logs_dir(root) tmp_files = list(log_dir.rglob("*.lock")) + list(log_dir.rglob("*.tmp")) assert not tmp_files, f"stale tmp files after delete: {tmp_files}" # --------------------------------------------------------------------------- # RL_17 — Integration: 3-entry log → delete @{1} → correct 2 entries remain # --------------------------------------------------------------------------- class TestDeleteIntegration: """RL_17: delete @{1} from a 3-entry log removes the middle entry.""" def test_correct_entries_remain_after_middle_delete(self, tmp_path: pathlib.Path) -> None: from muse.core.reflog import read_reflog root = _make_repo(tmp_path) _write_entries(_head_log(root), ["oldest", "middle", "newest"]) r = runner.invoke(cli, ["reflog", "delete", "@{1}", "--json"], env=_env(root)) assert r.exit_code == 0, r.output data = json.loads(r.output) assert data["deleted"] == 1 assert data["remaining"] == 2 entries = read_reflog(root, branch=None, limit=10) ops = [e.operation for e in entries] assert ops == ["newest", "oldest"], ( f"Expected ['newest', 'oldest'] (newest-first), got: {ops}" ) def test_entry_count_is_exact_after_delete(self, tmp_path: pathlib.Path) -> None: from muse.core.reflog import read_reflog root = _make_repo(tmp_path) _write_entries(_head_log(root), [f"entry-{i}" for i in range(10)]) runner.invoke(cli, ["reflog", "delete", "@{5}"], env=_env(root)) entries = read_reflog(root, branch=None, limit=100) assert len(entries) == 9 def test_repeated_deletes_converge_to_empty(self, tmp_path: pathlib.Path) -> None: """Deleting @{0} repeatedly empties the log without error.""" root = _make_repo(tmp_path) _write_entries(_head_log(root), ["a", "b", "c"]) for _ in range(3): r = runner.invoke(cli, ["reflog", "delete", "@{0}"], env=_env(root)) assert r.exit_code == 0, r.output assert not _head_log(root).exists() def test_branch_delete_does_not_touch_head_log(self, tmp_path: pathlib.Path) -> None: """Deleting from a branch log must not affect the HEAD log.""" root = _make_repo(tmp_path) _write_entries(_branch_log(root, "main"), ["b0", "b1", "b2"]) _write_entries(_head_log(root), ["h0", "h1"]) runner.invoke(cli, ["reflog", "delete", "@{1}", "--branch", "main"], env=_env(root)) head_ops = _read_operations(root) assert len(head_ops) == 2, "HEAD log must not be modified by branch delete"