gabriel / muse public

test_protected_branches.py file-level

at sha256:8 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:0 chore: bump version to 0.2.0rc14 · gabriel · Jun 20, 2026
1 """Tests for config-driven protected branch enforcement.
2
3 Protected branches are declared in ``.muse/config.toml``:
4
5 [protected_branches]
6 branches = ["main", "master", "dev", "release/*"]
7
8 Glob patterns (fnmatch) are supported so ``release/*`` protects any
9 ``release/x.y.z`` branch.
10
11 Coverage
12 --------
13 Unit β€” is_branch_protected
14 Exact match.
15 Glob match (release/*).
16 No match.
17 Empty pattern list never protects anything.
18 Case-sensitive (matches Python fnmatch).
19
20 Unit β€” get_protected_branches
21 Reads list from [protected_branches] in config.toml.
22 Returns empty list when section is absent.
23 Returns empty list when branches key is absent.
24 Ignores non-string entries in list.
25
26 Integration β€” muse branch -d / -D
27 Deleting a protected branch exits non-zero.
28 Force-delete (-D) of a protected branch also exits non-zero.
29 Deleting an unprotected branch still works.
30 Glob-protected branch is blocked (release/1.0 matches release/*).
31 Error output mentions "protected".
32 JSON error output contains error key and message.
33 Branch with no protected_branches config deletes normally.
34 Empty protected list does not block deletion.
35 """
36
37 from __future__ import annotations
38
39 import json
40 import pathlib
41
42 import pytest
43
44 from tests.cli_test_helper import CliRunner, InvokeResult
45 from muse.core.paths import config_toml_path
46
47 cli = None
48 runner = CliRunner()
49
50
51 # ---------------------------------------------------------------------------
52 # Helpers
53 # ---------------------------------------------------------------------------
54
55
56 def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult:
57 import os
58 saved = os.getcwd()
59 try:
60 os.chdir(repo)
61 return runner.invoke(cli, args)
62 finally:
63 os.chdir(saved)
64
65
66 def _branch(repo: pathlib.Path, *extra: str) -> InvokeResult:
67 return _invoke(repo, ["branch", *extra])
68
69
70 def _commit(repo: pathlib.Path, *extra: str) -> InvokeResult:
71 return _invoke(repo, ["commit", *extra])
72
73
74 def _write_protected(repo: pathlib.Path, branches: list[str]) -> None:
75 """Write a [protected_branches] section into the repo's config.toml."""
76 cp = config_toml_path(repo)
77 existing = cp.read_text(encoding="utf-8") if cp.exists() else ""
78 # Strip any existing protected_branches section then append fresh one.
79 lines = existing.splitlines()
80 filtered = []
81 skip = False
82 for line in lines:
83 if line.strip() == "[protected_branches]":
84 skip = True
85 continue
86 if skip and line.startswith("["):
87 skip = False
88 if not skip:
89 filtered.append(line)
90 base = "\n".join(filtered).rstrip()
91 branch_list = ", ".join(f'"{b}"' for b in branches)
92 cp.write_text(
93 f"{base}\n\n[protected_branches]\nbranches = [{branch_list}]\n",
94 encoding="utf-8",
95 )
96
97
98 @pytest.fixture
99 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
100 monkeypatch.chdir(tmp_path)
101 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
102 runner.invoke(cli, ["init"])
103 (tmp_path / "a.py").write_text("x = 1\n")
104 runner.invoke(cli, ["commit", "-m", "init"])
105 return tmp_path
106
107
108 # ---------------------------------------------------------------------------
109 # Unit β€” is_branch_protected
110 # ---------------------------------------------------------------------------
111
112
113 class TestIsBranchProtected:
114 def _fn(self, branch: str, patterns: list[str]) -> bool:
115 from muse.cli.config import is_branch_protected
116 return is_branch_protected(branch, patterns)
117
118 def test_exact_match(self) -> None:
119 assert self._fn("main", ["main", "dev"]) is True
120
121 def test_glob_match(self) -> None:
122 assert self._fn("release/1.0", ["release/*"]) is True
123
124 def test_glob_no_match(self) -> None:
125 assert self._fn("feat/x", ["release/*"]) is False
126
127 def test_no_match(self) -> None:
128 assert self._fn("feat/cool", ["main", "dev"]) is False
129
130 def test_empty_patterns_never_protects(self) -> None:
131 assert self._fn("main", []) is False
132
133 def test_case_sensitive(self) -> None:
134 assert self._fn("Main", ["main"]) is False
135
136 def test_double_star_glob(self) -> None:
137 assert self._fn("release/v1/hotfix", ["release/*/*"]) is True
138
139
140 # ---------------------------------------------------------------------------
141 # Unit β€” get_protected_branches
142 # ---------------------------------------------------------------------------
143
144
145 class TestGetProtectedBranches:
146 def test_reads_list_from_config(self, tmp_path: pathlib.Path) -> None:
147 from muse.cli.config import get_protected_branches
148
149 _invoke(tmp_path, ["init"])
150 _write_protected(tmp_path, ["main", "dev"])
151 result = get_protected_branches(tmp_path)
152 assert result == ["main", "dev"]
153
154 def test_returns_empty_when_section_absent(self, tmp_path: pathlib.Path) -> None:
155 from muse.cli.config import get_protected_branches
156
157 _invoke(tmp_path, ["init"])
158 result = get_protected_branches(tmp_path)
159 assert result == []
160
161 def test_returns_empty_when_branches_key_absent(self, tmp_path: pathlib.Path) -> None:
162 from muse.cli.config import get_protected_branches
163
164 _invoke(tmp_path, ["init"])
165 cp = config_toml_path(tmp_path)
166 existing = cp.read_text(encoding="utf-8") if cp.exists() else ""
167 cp.write_text(existing + "\n[protected_branches]\n", encoding="utf-8")
168 result = get_protected_branches(tmp_path)
169 assert result == []
170
171 def test_glob_pattern_preserved(self, tmp_path: pathlib.Path) -> None:
172 from muse.cli.config import get_protected_branches
173
174 _invoke(tmp_path, ["init"])
175 _write_protected(tmp_path, ["main", "release/*"])
176 result = get_protected_branches(tmp_path)
177 assert "release/*" in result
178
179
180 # ---------------------------------------------------------------------------
181 # Integration β€” branch deletion enforcement
182 # ---------------------------------------------------------------------------
183
184
185 class TestProtectedBranchDeletion:
186 def test_delete_protected_branch_exits_nonzero(
187 self, repo: pathlib.Path
188 ) -> None:
189 _write_protected(repo, ["main"])
190 result = _invoke(repo, ["checkout", "-b", "main-copy"])
191 _invoke(repo, ["checkout", "main-copy"])
192 # Try to delete main while on main-copy
193 result = _branch(repo, "-d", "main")
194 assert result.exit_code != 0
195
196 def test_force_delete_protected_branch_also_blocked(
197 self, repo: pathlib.Path
198 ) -> None:
199 """-D must not bypass protection β€” protection is explicit config intent."""
200 _write_protected(repo, ["main"])
201 _invoke(repo, ["checkout", "-b", "other"])
202 result = _branch(repo, "-D", "main")
203 assert result.exit_code != 0
204
205 def test_delete_unprotected_branch_succeeds(
206 self, repo: pathlib.Path
207 ) -> None:
208 _write_protected(repo, ["main", "dev"])
209 _invoke(repo, ["checkout", "-b", "temp"])
210 _invoke(repo, ["checkout", "main"])
211 result = _branch(repo, "-d", "temp")
212 assert result.exit_code == 0
213
214 def test_glob_protected_branch_blocked(
215 self, repo: pathlib.Path
216 ) -> None:
217 """release/* pattern blocks deletion of release/1.0."""
218 _write_protected(repo, ["release/*"])
219 _invoke(repo, ["checkout", "-b", "release/1.0"])
220 _invoke(repo, ["checkout", "main"])
221 result = _branch(repo, "-d", "release/1.0")
222 assert result.exit_code != 0
223
224 def test_error_mentions_protected(
225 self, repo: pathlib.Path
226 ) -> None:
227 _write_protected(repo, ["main"])
228 _invoke(repo, ["checkout", "-b", "other"])
229 result = _branch(repo, "-d", "main")
230 assert "protected" in result.stderr.lower()
231
232 def test_json_error_has_error_and_message(
233 self, repo: pathlib.Path
234 ) -> None:
235 _write_protected(repo, ["main"])
236 _invoke(repo, ["checkout", "-b", "other"])
237 result = _branch(repo, "-d", "main", "--json")
238 assert result.exit_code != 0
239 data = json.loads(result.output)
240 assert data.get("error") == "protected"
241 assert "message" in data
242 assert "main" in data["message"]
243
244 def test_no_protected_config_deletes_normally(
245 self, repo: pathlib.Path
246 ) -> None:
247 """Without any [protected_branches] config, deletion is unrestricted."""
248 _invoke(repo, ["checkout", "-b", "bye"])
249 _invoke(repo, ["checkout", "main"])
250 result = _branch(repo, "-d", "bye")
251 assert result.exit_code == 0
252
253 def test_empty_protected_list_does_not_block(
254 self, repo: pathlib.Path
255 ) -> None:
256 _write_protected(repo, [])
257 _invoke(repo, ["checkout", "-b", "safe"])
258 _invoke(repo, ["checkout", "main"])
259 result = _branch(repo, "-d", "safe")
260 assert result.exit_code == 0
261
262 def test_protected_branch_still_exists_after_blocked_delete(
263 self, repo: pathlib.Path
264 ) -> None:
265 """The ref must be untouched when deletion is blocked."""
266 _write_protected(repo, ["main"])
267 _invoke(repo, ["checkout", "-b", "other"])
268 _branch(repo, "-d", "main")
269 result = _branch(repo, "--json")
270 names = [b["name"] for b in json.loads(result.output)]
271 assert "main" in names