"""TDD tests for branch deletion pruning config.toml metadata. When ``muse branch -d`` or ``muse branch -D`` deletes a branch, the corresponding ``[branch.""]`` section in ``.muse/config.toml`` must also be removed. Previously the section accumulated forever, leaving hundreds of stale entries for long-dead branches. Coverage -------- - ``delete_branch_meta`` removes the section for a named branch. - ``delete_branch_meta`` is a no-op when the branch has no section. - ``delete_branch_meta`` preserves all other sections untouched. - ``muse branch -d`` removes the config section on successful deletion. - ``muse branch -D`` removes the config section on successful force-deletion. - Failed deletion (not merged, not found) does NOT remove the config section. - Deleting multiple branches in one call removes all their config sections. - A branch with no intent/resumable metadata still deletes cleanly (no crash). """ from __future__ import annotations import json import os import pathlib import pytest from tests.cli_test_helper import CliRunner, InvokeResult from muse.cli.config import read_branch_meta, write_branch_meta, delete_branch_meta from muse.core.paths import config_toml_path runner = CliRunner() type _ConfigSection = dict[str, str | bool | int] # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult: saved = os.getcwd() try: os.chdir(repo) return runner.invoke(None, args) finally: os.chdir(saved) def _branch(repo: pathlib.Path, *args: str) -> InvokeResult: return _invoke(repo, ["branch", *args]) def _commit(repo: pathlib.Path, msg: str = "commit") -> InvokeResult: (repo / f"_{msg}.py").write_text(f"# {msg}\n") _invoke(repo, ["code", "add", "."]) return _invoke(repo, ["commit", "-m", msg]) @pytest.fixture() def repo(tmp_path: pathlib.Path) -> pathlib.Path: saved = os.getcwd() try: os.chdir(tmp_path) runner.invoke(None, ["init"]) finally: os.chdir(saved) _commit(tmp_path, "initial") return tmp_path def _config_branch_section(repo: pathlib.Path, branch: str) -> _ConfigSection: """Return the raw config [branch.""] dict, or {} if absent.""" import tomllib cp = config_toml_path(repo) if not cp.exists(): return {} with cp.open("rb") as f: cfg = tomllib.load(f) return cfg.get("branch", {}).get(branch, {}) # --------------------------------------------------------------------------- # 1. Unit — delete_branch_meta # --------------------------------------------------------------------------- class TestDeleteBranchMeta: def test_removes_section_for_named_branch(self, repo: pathlib.Path) -> None: write_branch_meta(repo, "feat/gone", intent="doing a thing", resumable=True) assert _config_branch_section(repo, "feat/gone") != {} delete_branch_meta(repo, "feat/gone") assert _config_branch_section(repo, "feat/gone") == {} def test_noop_when_no_section_exists(self, repo: pathlib.Path) -> None: """delete_branch_meta must not raise when the branch has no config entry.""" delete_branch_meta(repo, "never/existed") # must not raise def test_preserves_other_branch_sections(self, repo: pathlib.Path) -> None: write_branch_meta(repo, "feat/keep", intent="keep me", resumable=True) write_branch_meta(repo, "feat/gone", intent="delete me") delete_branch_meta(repo, "feat/gone") assert _config_branch_section(repo, "feat/keep") == { "intent": "keep me", "resumable": True, } assert _config_branch_section(repo, "feat/gone") == {} def test_preserves_non_branch_config_sections(self, repo: pathlib.Path) -> None: """[user], [hub], [remotes.*] must survive a branch meta deletion.""" import tomllib write_branch_meta(repo, "feat/gone", intent="bye") cp = config_toml_path(repo) with cp.open("rb") as f: before = tomllib.load(f) delete_branch_meta(repo, "feat/gone") with cp.open("rb") as f: after = tomllib.load(f) for key in before: if key != "branch": assert after.get(key) == before[key] def test_noop_when_config_toml_absent(self, tmp_path: pathlib.Path) -> None: """No config.toml at all — must not raise.""" delete_branch_meta(tmp_path, "feat/gone") # must not raise # --------------------------------------------------------------------------- # 2. Integration — muse branch -d / -D prunes config # --------------------------------------------------------------------------- class TestBranchDeletePrunesConfig: def test_safe_delete_removes_config_section(self, repo: pathlib.Path) -> None: _invoke(repo, ["checkout", "-b", "feat/thing"]) _invoke(repo, ["checkout", "main"]) write_branch_meta(repo, "feat/thing", intent="doing a thing", resumable=True) assert _config_branch_section(repo, "feat/thing") != {} _branch(repo, "-d", "feat/thing") assert _config_branch_section(repo, "feat/thing") == {} def test_force_delete_removes_config_section(self, repo: pathlib.Path) -> None: _invoke(repo, ["checkout", "-b", "feat/unmerged"]) _commit(repo, "unmerged-work") _invoke(repo, ["checkout", "main"]) write_branch_meta(repo, "feat/unmerged", intent="unmerged", resumable=True) _branch(repo, "-D", "feat/unmerged") assert _config_branch_section(repo, "feat/unmerged") == {} def test_failed_delete_preserves_config_section(self, repo: pathlib.Path) -> None: """Branch not merged — -d fails; config section must survive.""" _invoke(repo, ["checkout", "-b", "feat/unmerged"]) _commit(repo, "unmerged-work") _invoke(repo, ["checkout", "main"]) write_branch_meta(repo, "feat/unmerged", intent="keep me") result = _branch(repo, "-d", "feat/unmerged") assert result.exit_code != 0 assert _config_branch_section(repo, "feat/unmerged") != {} def test_delete_branch_with_no_config_section(self, repo: pathlib.Path) -> None: """Deleting a branch that has no config entry must not crash.""" _invoke(repo, ["checkout", "-b", "feat/no-meta"]) _invoke(repo, ["checkout", "main"]) result = _branch(repo, "-d", "feat/no-meta") assert result.exit_code == 0 def test_delete_multiple_branches_removes_all_sections( self, repo: pathlib.Path ) -> None: _invoke(repo, ["checkout", "-b", "feat/a"]) _invoke(repo, ["checkout", "main"]) _invoke(repo, ["checkout", "-b", "feat/b"]) _invoke(repo, ["checkout", "main"]) write_branch_meta(repo, "feat/a", intent="a") write_branch_meta(repo, "feat/b", intent="b") _branch(repo, "-d", "feat/a", "feat/b") assert _config_branch_section(repo, "feat/a") == {} assert _config_branch_section(repo, "feat/b") == {} def test_json_output_unaffected(self, repo: pathlib.Path) -> None: """--json output shape is unchanged after the config cleanup.""" _invoke(repo, ["checkout", "-b", "feat/json-test"]) _invoke(repo, ["checkout", "main"]) write_branch_meta(repo, "feat/json-test", intent="test") result = _branch(repo, "-d", "--json", "feat/json-test") assert result.exit_code == 0 data = json.loads(result.output.strip()) assert data["action"] == "deleted" assert data["branch"] == "feat/json-test"