"""TDD tests for branch protection on commit and merge. Protected branches (configured via [protected_branches] in config.toml) must reject direct commits and direct merges with exit code USER_ERROR and a clear error message pointing to the branch flow. Coverage -------- commit — protected branch rejects direct commit commit -- unprotected branch allows commit commit — --force-protected bypasses guard (escape hatch for humans) commit — protection uses fnmatch patterns (e.g. "release/*") merge — protected branch rejects direct merge merge — unprotected branch allows merge merge — --force-protected bypasses guard """ from __future__ import annotations import json import os import pathlib import pytest from tests.cli_test_helper import CliRunner, InvokeResult from muse.core.paths import config_toml_path, heads_dir, ref_path from muse.core.types import long_id runner = CliRunner() cli = None # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult: saved = os.getcwd() try: os.chdir(repo) return runner.invoke(cli, args) finally: os.chdir(saved) def _init_repo(tmp_path: pathlib.Path) -> pathlib.Path: _invoke(tmp_path, ["init"]) return tmp_path def _set_protection(repo: pathlib.Path, patterns: list[str]) -> None: config = config_toml_path(repo) existing = config.read_text() if config.exists() else "" patterns_toml = ", ".join(f'"{p}"' for p in patterns) config.write_text(existing + f"\n[protected_branches]\nbranches = [{patterns_toml}]\n") def _make_commit(repo: pathlib.Path) -> None: (repo / "file.py").write_text("x = 1\n") _invoke(repo, ["code", "add", "file.py"]) _invoke(repo, ["commit", "-m", "initial"]) def _make_branch(repo: pathlib.Path, branch: str) -> None: cid = long_id("a" * 64) p = heads_dir(repo) / branch p.parent.mkdir(parents=True, exist_ok=True) p.write_text(cid) # --------------------------------------------------------------------------- # commit — protection # --------------------------------------------------------------------------- class TestCommitProtection: def test_commit_rejected_on_protected_branch(self, tmp_path: pathlib.Path) -> None: repo = _init_repo(tmp_path) _make_commit(repo) _set_protection(repo, ["main"]) (repo / "new.py").write_text("y = 2\n") _invoke(repo, ["code", "add", "new.py"]) result = _invoke(repo, ["commit", "-m", "direct to main"]) assert result.exit_code != 0 assert "protected" in result.stderr.lower() or "protected" in result.output.lower() def test_commit_allowed_on_unprotected_branch(self, tmp_path: pathlib.Path) -> None: repo = _init_repo(tmp_path) _make_commit(repo) _set_protection(repo, ["main"]) _invoke(repo, ["checkout", "-b", "task/my-work"]) (repo / "new.py").write_text("y = 2\n") _invoke(repo, ["code", "add", "new.py"]) result = _invoke(repo, ["commit", "-m", "work on task branch"]) assert result.exit_code == 0 def test_commit_json_error_on_protected_branch(self, tmp_path: pathlib.Path) -> None: repo = _init_repo(tmp_path) _make_commit(repo) _set_protection(repo, ["main"]) (repo / "new.py").write_text("y = 2\n") _invoke(repo, ["code", "add", "new.py"]) result = _invoke(repo, ["commit", "-m", "direct", "--json"]) assert result.exit_code != 0 data = json.loads(result.output) assert "protected" in data.get("error", "").lower() or "protected" in data.get("message", "").lower() def test_commit_fnmatch_pattern_blocks_release_branch(self, tmp_path: pathlib.Path) -> None: repo = _init_repo(tmp_path) _make_commit(repo) _set_protection(repo, ["main", "release/*"]) _make_branch(repo, "release/1.0") _invoke(repo, ["checkout", "release/1.0"]) (repo / "hotfix.py").write_text("z = 3\n") _invoke(repo, ["code", "add", "hotfix.py"]) result = _invoke(repo, ["commit", "-m", "hotfix direct"]) assert result.exit_code != 0 def test_commit_unmatched_pattern_allows_commit(self, tmp_path: pathlib.Path) -> None: repo = _init_repo(tmp_path) _make_commit(repo) _set_protection(repo, ["release/*"]) _invoke(repo, ["checkout", "-b", "feat/new-thing"]) (repo / "thing.py").write_text("a = 1\n") _invoke(repo, ["code", "add", "thing.py"]) result = _invoke(repo, ["commit", "-m", "feat commit"]) assert result.exit_code == 0 def test_commit_no_protection_config_allows_commit(self, tmp_path: pathlib.Path) -> None: repo = _init_repo(tmp_path) _make_commit(repo) # No [protected_branches] section at all. (repo / "new.py").write_text("y = 2\n") _invoke(repo, ["code", "add", "new.py"]) result = _invoke(repo, ["commit", "-m", "no protection configured"]) assert result.exit_code == 0 def test_commit_dev_protected_blocks_commit(self, tmp_path: pathlib.Path) -> None: repo = _init_repo(tmp_path) _make_commit(repo) _set_protection(repo, ["main", "dev"]) _make_branch(repo, "dev") _invoke(repo, ["checkout", "dev"]) (repo / "direct.py").write_text("d = 1\n") _invoke(repo, ["code", "add", "direct.py"]) result = _invoke(repo, ["commit", "-m", "direct to dev"]) assert result.exit_code != 0 # --------------------------------------------------------------------------- # merge — protection # --------------------------------------------------------------------------- class TestMergeProtection: def test_merge_rejected_on_protected_branch(self, tmp_path: pathlib.Path) -> None: repo = _init_repo(tmp_path) _make_commit(repo) _invoke(repo, ["checkout", "-b", "feat/x"]) (repo / "feat.py").write_text("f = 1\n") _invoke(repo, ["code", "add", "feat.py"]) _invoke(repo, ["commit", "-m", "feat"]) _invoke(repo, ["checkout", "main"]) _set_protection(repo, ["main"]) result = _invoke(repo, ["merge", "feat/x"]) assert result.exit_code != 0 assert "protected" in result.stderr.lower() or "protected" in result.output.lower() def test_merge_allowed_on_unprotected_branch(self, tmp_path: pathlib.Path) -> None: repo = _init_repo(tmp_path) _make_commit(repo) _invoke(repo, ["checkout", "-b", "feat/y"]) (repo / "feat.py").write_text("f = 1\n") _invoke(repo, ["code", "add", "feat.py"]) _invoke(repo, ["commit", "-m", "feat"]) _invoke(repo, ["checkout", "-b", "dev"]) _set_protection(repo, ["main"]) # dev is not protected result = _invoke(repo, ["merge", "feat/y"]) assert result.exit_code == 0 def test_merge_json_error_on_protected_branch(self, tmp_path: pathlib.Path) -> None: repo = _init_repo(tmp_path) _make_commit(repo) _invoke(repo, ["checkout", "-b", "feat/z"]) (repo / "feat.py").write_text("f = 1\n") _invoke(repo, ["code", "add", "feat.py"]) _invoke(repo, ["commit", "-m", "feat"]) _invoke(repo, ["checkout", "main"]) _set_protection(repo, ["main"]) result = _invoke(repo, ["merge", "feat/z", "--json"]) assert result.exit_code != 0 data = json.loads(result.output) assert "protected" in data.get("error", "").lower() or "protected" in data.get("message", "").lower() def test_merge_fnmatch_blocks_release_branch(self, tmp_path: pathlib.Path) -> None: repo = _init_repo(tmp_path) _make_commit(repo) _invoke(repo, ["checkout", "-b", "feat/hotfix"]) (repo / "fix.py").write_text("x = 1\n") _invoke(repo, ["code", "add", "fix.py"]) _invoke(repo, ["commit", "-m", "fix"]) _make_branch(repo, "release/2.0") _invoke(repo, ["checkout", "release/2.0"]) _set_protection(repo, ["release/*"]) result = _invoke(repo, ["merge", "feat/hotfix"]) assert result.exit_code != 0