"""Tests for config-driven protected branch enforcement. Protected branches are declared in ``.muse/config.toml``: [protected_branches] branches = ["main", "master", "dev", "release/*"] Glob patterns (fnmatch) are supported so ``release/*`` protects any ``release/x.y.z`` branch. Coverage -------- Unit — is_branch_protected Exact match. Glob match (release/*). No match. Empty pattern list never protects anything. Case-sensitive (matches Python fnmatch). Unit — get_protected_branches Reads list from [protected_branches] in config.toml. Returns empty list when section is absent. Returns empty list when branches key is absent. Ignores non-string entries in list. Integration — muse branch -d / -D Deleting a protected branch exits non-zero. Force-delete (-D) of a protected branch also exits non-zero. Deleting an unprotected branch still works. Glob-protected branch is blocked (release/1.0 matches release/*). Error output mentions "protected". JSON error output contains error key and message. Branch with no protected_branches config deletes normally. Empty protected list does not block deletion. """ from __future__ import annotations import json import pathlib import pytest from tests.cli_test_helper import CliRunner, InvokeResult from muse.core.paths import config_toml_path cli = None runner = CliRunner() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult: import os saved = os.getcwd() try: os.chdir(repo) return runner.invoke(cli, args) finally: os.chdir(saved) def _branch(repo: pathlib.Path, *extra: str) -> InvokeResult: return _invoke(repo, ["branch", *extra]) def _commit(repo: pathlib.Path, *extra: str) -> InvokeResult: return _invoke(repo, ["commit", *extra]) def _write_protected(repo: pathlib.Path, branches: list[str]) -> None: """Write a [protected_branches] section into the repo's config.toml.""" cp = config_toml_path(repo) existing = cp.read_text(encoding="utf-8") if cp.exists() else "" # Strip any existing protected_branches section then append fresh one. lines = existing.splitlines() filtered = [] skip = False for line in lines: if line.strip() == "[protected_branches]": skip = True continue if skip and line.startswith("["): skip = False if not skip: filtered.append(line) base = "\n".join(filtered).rstrip() branch_list = ", ".join(f'"{b}"' for b in branches) cp.write_text( f"{base}\n\n[protected_branches]\nbranches = [{branch_list}]\n", encoding="utf-8", ) @pytest.fixture def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: monkeypatch.chdir(tmp_path) monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path)) runner.invoke(cli, ["init"]) (tmp_path / "a.py").write_text("x = 1\n") runner.invoke(cli, ["commit", "-m", "init"]) return tmp_path # --------------------------------------------------------------------------- # Unit — is_branch_protected # --------------------------------------------------------------------------- class TestIsBranchProtected: def _fn(self, branch: str, patterns: list[str]) -> bool: from muse.cli.config import is_branch_protected return is_branch_protected(branch, patterns) def test_exact_match(self) -> None: assert self._fn("main", ["main", "dev"]) is True def test_glob_match(self) -> None: assert self._fn("release/1.0", ["release/*"]) is True def test_glob_no_match(self) -> None: assert self._fn("feat/x", ["release/*"]) is False def test_no_match(self) -> None: assert self._fn("feat/cool", ["main", "dev"]) is False def test_empty_patterns_never_protects(self) -> None: assert self._fn("main", []) is False def test_case_sensitive(self) -> None: assert self._fn("Main", ["main"]) is False def test_double_star_glob(self) -> None: assert self._fn("release/v1/hotfix", ["release/*/*"]) is True # --------------------------------------------------------------------------- # Unit — get_protected_branches # --------------------------------------------------------------------------- class TestGetProtectedBranches: def test_reads_list_from_config(self, tmp_path: pathlib.Path) -> None: from muse.cli.config import get_protected_branches _invoke(tmp_path, ["init"]) _write_protected(tmp_path, ["main", "dev"]) result = get_protected_branches(tmp_path) assert result == ["main", "dev"] def test_returns_empty_when_section_absent(self, tmp_path: pathlib.Path) -> None: from muse.cli.config import get_protected_branches _invoke(tmp_path, ["init"]) result = get_protected_branches(tmp_path) assert result == [] def test_returns_empty_when_branches_key_absent(self, tmp_path: pathlib.Path) -> None: from muse.cli.config import get_protected_branches _invoke(tmp_path, ["init"]) cp = config_toml_path(tmp_path) existing = cp.read_text(encoding="utf-8") if cp.exists() else "" cp.write_text(existing + "\n[protected_branches]\n", encoding="utf-8") result = get_protected_branches(tmp_path) assert result == [] def test_glob_pattern_preserved(self, tmp_path: pathlib.Path) -> None: from muse.cli.config import get_protected_branches _invoke(tmp_path, ["init"]) _write_protected(tmp_path, ["main", "release/*"]) result = get_protected_branches(tmp_path) assert "release/*" in result # --------------------------------------------------------------------------- # Integration — branch deletion enforcement # --------------------------------------------------------------------------- class TestProtectedBranchDeletion: def test_delete_protected_branch_exits_nonzero( self, repo: pathlib.Path ) -> None: _write_protected(repo, ["main"]) result = _invoke(repo, ["checkout", "-b", "main-copy"]) _invoke(repo, ["checkout", "main-copy"]) # Try to delete main while on main-copy result = _branch(repo, "-d", "main") assert result.exit_code != 0 def test_force_delete_protected_branch_also_blocked( self, repo: pathlib.Path ) -> None: """-D must not bypass protection — protection is explicit config intent.""" _write_protected(repo, ["main"]) _invoke(repo, ["checkout", "-b", "other"]) result = _branch(repo, "-D", "main") assert result.exit_code != 0 def test_delete_unprotected_branch_succeeds( self, repo: pathlib.Path ) -> None: _write_protected(repo, ["main", "dev"]) _invoke(repo, ["checkout", "-b", "temp"]) _invoke(repo, ["checkout", "main"]) result = _branch(repo, "-d", "temp") assert result.exit_code == 0 def test_glob_protected_branch_blocked( self, repo: pathlib.Path ) -> None: """release/* pattern blocks deletion of release/1.0.""" _write_protected(repo, ["release/*"]) _invoke(repo, ["checkout", "-b", "release/1.0"]) _invoke(repo, ["checkout", "main"]) result = _branch(repo, "-d", "release/1.0") assert result.exit_code != 0 def test_error_mentions_protected( self, repo: pathlib.Path ) -> None: _write_protected(repo, ["main"]) _invoke(repo, ["checkout", "-b", "other"]) result = _branch(repo, "-d", "main") assert "protected" in result.stderr.lower() def test_json_error_has_error_and_message( self, repo: pathlib.Path ) -> None: _write_protected(repo, ["main"]) _invoke(repo, ["checkout", "-b", "other"]) result = _branch(repo, "-d", "main", "--json") assert result.exit_code != 0 data = json.loads(result.output) assert data.get("error") == "protected" assert "message" in data assert "main" in data["message"] def test_no_protected_config_deletes_normally( self, repo: pathlib.Path ) -> None: """Without any [protected_branches] config, deletion is unrestricted.""" _invoke(repo, ["checkout", "-b", "bye"]) _invoke(repo, ["checkout", "main"]) result = _branch(repo, "-d", "bye") assert result.exit_code == 0 def test_empty_protected_list_does_not_block( self, repo: pathlib.Path ) -> None: _write_protected(repo, []) _invoke(repo, ["checkout", "-b", "safe"]) _invoke(repo, ["checkout", "main"]) result = _branch(repo, "-d", "safe") assert result.exit_code == 0 def test_protected_branch_still_exists_after_blocked_delete( self, repo: pathlib.Path ) -> None: """The ref must be untouched when deletion is blocked.""" _write_protected(repo, ["main"]) _invoke(repo, ["checkout", "-b", "other"]) _branch(repo, "-d", "main") result = _branch(repo, "--json") names = [b["name"] for b in json.loads(result.output)] assert "main" in names