gabriel / muse public
test_branch_delete_config_cleanup.py python
206 lines 7.6 KB
Raw
1 """TDD tests for branch deletion pruning config.toml metadata.
2
3 When ``muse branch -d`` or ``muse branch -D`` deletes a branch, the
4 corresponding ``[branch."<name>"]`` section in ``.muse/config.toml`` must
5 also be removed. Previously the section accumulated forever, leaving
6 hundreds of stale entries for long-dead branches.
7
8 Coverage
9 --------
10 - ``delete_branch_meta`` removes the section for a named branch.
11 - ``delete_branch_meta`` is a no-op when the branch has no section.
12 - ``delete_branch_meta`` preserves all other sections untouched.
13 - ``muse branch -d`` removes the config section on successful deletion.
14 - ``muse branch -D`` removes the config section on successful force-deletion.
15 - Failed deletion (not merged, not found) does NOT remove the config section.
16 - Deleting multiple branches in one call removes all their config sections.
17 - A branch with no intent/resumable metadata still deletes cleanly (no crash).
18 """
19
20 from __future__ import annotations
21
22 import json
23 import os
24 import pathlib
25
26 import pytest
27
28 from tests.cli_test_helper import CliRunner, InvokeResult
29 from muse.cli.config import read_branch_meta, write_branch_meta, delete_branch_meta
30 from muse.core.paths import config_toml_path
31
32 runner = CliRunner()
33
34 type _ConfigSection = dict[str, str | bool | int]
35
36
37 # ---------------------------------------------------------------------------
38 # Helpers
39 # ---------------------------------------------------------------------------
40
41
42 def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult:
43 saved = os.getcwd()
44 try:
45 os.chdir(repo)
46 return runner.invoke(None, args)
47 finally:
48 os.chdir(saved)
49
50
51 def _branch(repo: pathlib.Path, *args: str) -> InvokeResult:
52 return _invoke(repo, ["branch", *args])
53
54
55 def _commit(repo: pathlib.Path, msg: str = "commit") -> InvokeResult:
56 (repo / f"_{msg}.py").write_text(f"# {msg}\n")
57 _invoke(repo, ["code", "add", "."])
58 return _invoke(repo, ["commit", "-m", msg])
59
60
61 @pytest.fixture()
62 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
63 saved = os.getcwd()
64 try:
65 os.chdir(tmp_path)
66 runner.invoke(None, ["init"])
67 finally:
68 os.chdir(saved)
69 _commit(tmp_path, "initial")
70 return tmp_path
71
72
73 def _config_branch_section(repo: pathlib.Path, branch: str) -> _ConfigSection:
74 """Return the raw config [branch."<name>"] dict, or {} if absent."""
75 import tomllib
76 cp = config_toml_path(repo)
77 if not cp.exists():
78 return {}
79 with cp.open("rb") as f:
80 cfg = tomllib.load(f)
81 return cfg.get("branch", {}).get(branch, {})
82
83
84 # ---------------------------------------------------------------------------
85 # 1. Unit — delete_branch_meta
86 # ---------------------------------------------------------------------------
87
88
89 class TestDeleteBranchMeta:
90 def test_removes_section_for_named_branch(self, repo: pathlib.Path) -> None:
91 write_branch_meta(repo, "feat/gone", intent="doing a thing", resumable=True)
92 assert _config_branch_section(repo, "feat/gone") != {}
93
94 delete_branch_meta(repo, "feat/gone")
95
96 assert _config_branch_section(repo, "feat/gone") == {}
97
98 def test_noop_when_no_section_exists(self, repo: pathlib.Path) -> None:
99 """delete_branch_meta must not raise when the branch has no config entry."""
100 delete_branch_meta(repo, "never/existed") # must not raise
101
102 def test_preserves_other_branch_sections(self, repo: pathlib.Path) -> None:
103 write_branch_meta(repo, "feat/keep", intent="keep me", resumable=True)
104 write_branch_meta(repo, "feat/gone", intent="delete me")
105
106 delete_branch_meta(repo, "feat/gone")
107
108 assert _config_branch_section(repo, "feat/keep") == {
109 "intent": "keep me",
110 "resumable": True,
111 }
112 assert _config_branch_section(repo, "feat/gone") == {}
113
114 def test_preserves_non_branch_config_sections(self, repo: pathlib.Path) -> None:
115 """[user], [hub], [remotes.*] must survive a branch meta deletion."""
116 import tomllib
117 write_branch_meta(repo, "feat/gone", intent="bye")
118 cp = config_toml_path(repo)
119 with cp.open("rb") as f:
120 before = tomllib.load(f)
121
122 delete_branch_meta(repo, "feat/gone")
123
124 with cp.open("rb") as f:
125 after = tomllib.load(f)
126 for key in before:
127 if key != "branch":
128 assert after.get(key) == before[key]
129
130 def test_noop_when_config_toml_absent(self, tmp_path: pathlib.Path) -> None:
131 """No config.toml at all — must not raise."""
132 delete_branch_meta(tmp_path, "feat/gone") # must not raise
133
134
135 # ---------------------------------------------------------------------------
136 # 2. Integration — muse branch -d / -D prunes config
137 # ---------------------------------------------------------------------------
138
139
140 class TestBranchDeletePrunesConfig:
141 def test_safe_delete_removes_config_section(self, repo: pathlib.Path) -> None:
142 _invoke(repo, ["checkout", "-b", "feat/thing"])
143 _invoke(repo, ["checkout", "main"])
144 write_branch_meta(repo, "feat/thing", intent="doing a thing", resumable=True)
145 assert _config_branch_section(repo, "feat/thing") != {}
146
147 _branch(repo, "-d", "feat/thing")
148
149 assert _config_branch_section(repo, "feat/thing") == {}
150
151 def test_force_delete_removes_config_section(self, repo: pathlib.Path) -> None:
152 _invoke(repo, ["checkout", "-b", "feat/unmerged"])
153 _commit(repo, "unmerged-work")
154 _invoke(repo, ["checkout", "main"])
155 write_branch_meta(repo, "feat/unmerged", intent="unmerged", resumable=True)
156
157 _branch(repo, "-D", "feat/unmerged")
158
159 assert _config_branch_section(repo, "feat/unmerged") == {}
160
161 def test_failed_delete_preserves_config_section(self, repo: pathlib.Path) -> None:
162 """Branch not merged — -d fails; config section must survive."""
163 _invoke(repo, ["checkout", "-b", "feat/unmerged"])
164 _commit(repo, "unmerged-work")
165 _invoke(repo, ["checkout", "main"])
166 write_branch_meta(repo, "feat/unmerged", intent="keep me")
167
168 result = _branch(repo, "-d", "feat/unmerged")
169 assert result.exit_code != 0
170
171 assert _config_branch_section(repo, "feat/unmerged") != {}
172
173 def test_delete_branch_with_no_config_section(self, repo: pathlib.Path) -> None:
174 """Deleting a branch that has no config entry must not crash."""
175 _invoke(repo, ["checkout", "-b", "feat/no-meta"])
176 _invoke(repo, ["checkout", "main"])
177 result = _branch(repo, "-d", "feat/no-meta")
178 assert result.exit_code == 0
179
180 def test_delete_multiple_branches_removes_all_sections(
181 self, repo: pathlib.Path
182 ) -> None:
183 _invoke(repo, ["checkout", "-b", "feat/a"])
184 _invoke(repo, ["checkout", "main"])
185 _invoke(repo, ["checkout", "-b", "feat/b"])
186 _invoke(repo, ["checkout", "main"])
187 write_branch_meta(repo, "feat/a", intent="a")
188 write_branch_meta(repo, "feat/b", intent="b")
189
190 _branch(repo, "-d", "feat/a", "feat/b")
191
192 assert _config_branch_section(repo, "feat/a") == {}
193 assert _config_branch_section(repo, "feat/b") == {}
194
195 def test_json_output_unaffected(self, repo: pathlib.Path) -> None:
196 """--json output shape is unchanged after the config cleanup."""
197 _invoke(repo, ["checkout", "-b", "feat/json-test"])
198 _invoke(repo, ["checkout", "main"])
199 write_branch_meta(repo, "feat/json-test", intent="test")
200
201 result = _branch(repo, "-d", "--json", "feat/json-test")
202
203 assert result.exit_code == 0
204 data = json.loads(result.output.strip())
205 assert data["action"] == "deleted"
206 assert data["branch"] == "feat/json-test"
File History 1 commit