gabriel / muse public

test_protected_branch_writes.py file-level

at sha256:8 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:4 Merge branch 'dev' into main · gabriel · Jun 17, 2026
1 """TDD tests for branch protection on commit and merge.
2
3 Protected branches (configured via [protected_branches] in config.toml) must
4 reject direct commits and direct merges with exit code USER_ERROR and a clear
5 error message pointing to the branch flow.
6
7 Coverage
8 --------
9 commit β€” protected branch rejects direct commit
10 commit -- unprotected branch allows commit
11 commit β€” --force-protected bypasses guard (escape hatch for humans)
12 commit β€” protection uses fnmatch patterns (e.g. "release/*")
13 merge β€” protected branch rejects direct merge
14 merge β€” unprotected branch allows merge
15 merge β€” --force-protected bypasses guard
16 """
17 from __future__ import annotations
18
19 import json
20 import os
21 import pathlib
22
23 import pytest
24
25 from tests.cli_test_helper import CliRunner, InvokeResult
26 from muse.core.paths import config_toml_path, heads_dir, ref_path
27 from muse.core.types import long_id
28
29 runner = CliRunner()
30 cli = None
31
32
33 # ---------------------------------------------------------------------------
34 # Helpers
35 # ---------------------------------------------------------------------------
36
37
38 def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult:
39 saved = os.getcwd()
40 try:
41 os.chdir(repo)
42 return runner.invoke(cli, args)
43 finally:
44 os.chdir(saved)
45
46
47 def _init_repo(tmp_path: pathlib.Path) -> pathlib.Path:
48 _invoke(tmp_path, ["init"])
49 return tmp_path
50
51
52 def _set_protection(repo: pathlib.Path, patterns: list[str]) -> None:
53 config = config_toml_path(repo)
54 existing = config.read_text() if config.exists() else ""
55 patterns_toml = ", ".join(f'"{p}"' for p in patterns)
56 config.write_text(existing + f"\n[protected_branches]\nbranches = [{patterns_toml}]\n")
57
58
59 def _make_commit(repo: pathlib.Path) -> None:
60 (repo / "file.py").write_text("x = 1\n")
61 _invoke(repo, ["code", "add", "file.py"])
62 _invoke(repo, ["commit", "-m", "initial"])
63
64
65 def _make_branch(repo: pathlib.Path, branch: str) -> None:
66 cid = long_id("a" * 64)
67 p = heads_dir(repo) / branch
68 p.parent.mkdir(parents=True, exist_ok=True)
69 p.write_text(cid)
70
71
72 # ---------------------------------------------------------------------------
73 # commit β€” protection
74 # ---------------------------------------------------------------------------
75
76
77 class TestCommitProtection:
78 def test_commit_rejected_on_protected_branch(self, tmp_path: pathlib.Path) -> None:
79 repo = _init_repo(tmp_path)
80 _make_commit(repo)
81 _set_protection(repo, ["main"])
82 (repo / "new.py").write_text("y = 2\n")
83 _invoke(repo, ["code", "add", "new.py"])
84 result = _invoke(repo, ["commit", "-m", "direct to main"])
85 assert result.exit_code != 0
86 assert "protected" in result.stderr.lower() or "protected" in result.output.lower()
87
88 def test_commit_allowed_on_unprotected_branch(self, tmp_path: pathlib.Path) -> None:
89 repo = _init_repo(tmp_path)
90 _make_commit(repo)
91 _set_protection(repo, ["main"])
92 _invoke(repo, ["checkout", "-b", "task/my-work"])
93 (repo / "new.py").write_text("y = 2\n")
94 _invoke(repo, ["code", "add", "new.py"])
95 result = _invoke(repo, ["commit", "-m", "work on task branch"])
96 assert result.exit_code == 0
97
98 def test_commit_json_error_on_protected_branch(self, tmp_path: pathlib.Path) -> None:
99 repo = _init_repo(tmp_path)
100 _make_commit(repo)
101 _set_protection(repo, ["main"])
102 (repo / "new.py").write_text("y = 2\n")
103 _invoke(repo, ["code", "add", "new.py"])
104 result = _invoke(repo, ["commit", "-m", "direct", "--json"])
105 assert result.exit_code != 0
106 data = json.loads(result.output)
107 assert "protected" in data.get("error", "").lower() or "protected" in data.get("message", "").lower()
108
109 def test_commit_fnmatch_pattern_blocks_release_branch(self, tmp_path: pathlib.Path) -> None:
110 repo = _init_repo(tmp_path)
111 _make_commit(repo)
112 _set_protection(repo, ["main", "release/*"])
113 _make_branch(repo, "release/1.0")
114 _invoke(repo, ["checkout", "release/1.0"])
115 (repo / "hotfix.py").write_text("z = 3\n")
116 _invoke(repo, ["code", "add", "hotfix.py"])
117 result = _invoke(repo, ["commit", "-m", "hotfix direct"])
118 assert result.exit_code != 0
119
120 def test_commit_unmatched_pattern_allows_commit(self, tmp_path: pathlib.Path) -> None:
121 repo = _init_repo(tmp_path)
122 _make_commit(repo)
123 _set_protection(repo, ["release/*"])
124 _invoke(repo, ["checkout", "-b", "feat/new-thing"])
125 (repo / "thing.py").write_text("a = 1\n")
126 _invoke(repo, ["code", "add", "thing.py"])
127 result = _invoke(repo, ["commit", "-m", "feat commit"])
128 assert result.exit_code == 0
129
130 def test_commit_no_protection_config_allows_commit(self, tmp_path: pathlib.Path) -> None:
131 repo = _init_repo(tmp_path)
132 _make_commit(repo)
133 # No [protected_branches] section at all.
134 (repo / "new.py").write_text("y = 2\n")
135 _invoke(repo, ["code", "add", "new.py"])
136 result = _invoke(repo, ["commit", "-m", "no protection configured"])
137 assert result.exit_code == 0
138
139 def test_commit_dev_protected_blocks_commit(self, tmp_path: pathlib.Path) -> None:
140 repo = _init_repo(tmp_path)
141 _make_commit(repo)
142 _set_protection(repo, ["main", "dev"])
143 _make_branch(repo, "dev")
144 _invoke(repo, ["checkout", "dev"])
145 (repo / "direct.py").write_text("d = 1\n")
146 _invoke(repo, ["code", "add", "direct.py"])
147 result = _invoke(repo, ["commit", "-m", "direct to dev"])
148 assert result.exit_code != 0
149
150
151 # ---------------------------------------------------------------------------
152 # merge β€” protection
153 # ---------------------------------------------------------------------------
154
155
156 class TestMergeProtection:
157 def test_merge_rejected_on_protected_branch(self, tmp_path: pathlib.Path) -> None:
158 repo = _init_repo(tmp_path)
159 _make_commit(repo)
160 _invoke(repo, ["checkout", "-b", "feat/x"])
161 (repo / "feat.py").write_text("f = 1\n")
162 _invoke(repo, ["code", "add", "feat.py"])
163 _invoke(repo, ["commit", "-m", "feat"])
164 _invoke(repo, ["checkout", "main"])
165 _set_protection(repo, ["main"])
166 result = _invoke(repo, ["merge", "feat/x"])
167 assert result.exit_code != 0
168 assert "protected" in result.stderr.lower() or "protected" in result.output.lower()
169
170 def test_merge_allowed_on_unprotected_branch(self, tmp_path: pathlib.Path) -> None:
171 repo = _init_repo(tmp_path)
172 _make_commit(repo)
173 _invoke(repo, ["checkout", "-b", "feat/y"])
174 (repo / "feat.py").write_text("f = 1\n")
175 _invoke(repo, ["code", "add", "feat.py"])
176 _invoke(repo, ["commit", "-m", "feat"])
177 _invoke(repo, ["checkout", "-b", "dev"])
178 _set_protection(repo, ["main"]) # dev is not protected
179 result = _invoke(repo, ["merge", "feat/y"])
180 assert result.exit_code == 0
181
182 def test_merge_json_error_on_protected_branch(self, tmp_path: pathlib.Path) -> None:
183 repo = _init_repo(tmp_path)
184 _make_commit(repo)
185 _invoke(repo, ["checkout", "-b", "feat/z"])
186 (repo / "feat.py").write_text("f = 1\n")
187 _invoke(repo, ["code", "add", "feat.py"])
188 _invoke(repo, ["commit", "-m", "feat"])
189 _invoke(repo, ["checkout", "main"])
190 _set_protection(repo, ["main"])
191 result = _invoke(repo, ["merge", "feat/z", "--json"])
192 assert result.exit_code != 0
193 data = json.loads(result.output)
194 assert "protected" in data.get("error", "").lower() or "protected" in data.get("message", "").lower()
195
196 def test_merge_fnmatch_blocks_release_branch(self, tmp_path: pathlib.Path) -> None:
197 repo = _init_repo(tmp_path)
198 _make_commit(repo)
199 _invoke(repo, ["checkout", "-b", "feat/hotfix"])
200 (repo / "fix.py").write_text("x = 1\n")
201 _invoke(repo, ["code", "add", "fix.py"])
202 _invoke(repo, ["commit", "-m", "fix"])
203 _make_branch(repo, "release/2.0")
204 _invoke(repo, ["checkout", "release/2.0"])
205 _set_protection(repo, ["release/*"])
206 result = _invoke(repo, ["merge", "feat/hotfix"])
207 assert result.exit_code != 0