gabriel / muse public
test_branch_delete_config_cleanup.py python
205 lines 7.5 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 return _invoke(repo, ["commit", "-m", msg])
58
59
60 @pytest.fixture()
61 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
62 saved = os.getcwd()
63 try:
64 os.chdir(tmp_path)
65 runner.invoke(None, ["init"])
66 finally:
67 os.chdir(saved)
68 _commit(tmp_path, "initial")
69 return tmp_path
70
71
72 def _config_branch_section(repo: pathlib.Path, branch: str) -> _ConfigSection:
73 """Return the raw config [branch."<name>"] dict, or {} if absent."""
74 import tomllib
75 cp = config_toml_path(repo)
76 if not cp.exists():
77 return {}
78 with cp.open("rb") as f:
79 cfg = tomllib.load(f)
80 return cfg.get("branch", {}).get(branch, {})
81
82
83 # ---------------------------------------------------------------------------
84 # 1. Unit — delete_branch_meta
85 # ---------------------------------------------------------------------------
86
87
88 class TestDeleteBranchMeta:
89 def test_removes_section_for_named_branch(self, repo: pathlib.Path) -> None:
90 write_branch_meta(repo, "feat/gone", intent="doing a thing", resumable=True)
91 assert _config_branch_section(repo, "feat/gone") != {}
92
93 delete_branch_meta(repo, "feat/gone")
94
95 assert _config_branch_section(repo, "feat/gone") == {}
96
97 def test_noop_when_no_section_exists(self, repo: pathlib.Path) -> None:
98 """delete_branch_meta must not raise when the branch has no config entry."""
99 delete_branch_meta(repo, "never/existed") # must not raise
100
101 def test_preserves_other_branch_sections(self, repo: pathlib.Path) -> None:
102 write_branch_meta(repo, "feat/keep", intent="keep me", resumable=True)
103 write_branch_meta(repo, "feat/gone", intent="delete me")
104
105 delete_branch_meta(repo, "feat/gone")
106
107 assert _config_branch_section(repo, "feat/keep") == {
108 "intent": "keep me",
109 "resumable": True,
110 }
111 assert _config_branch_section(repo, "feat/gone") == {}
112
113 def test_preserves_non_branch_config_sections(self, repo: pathlib.Path) -> None:
114 """[user], [hub], [remotes.*] must survive a branch meta deletion."""
115 import tomllib
116 write_branch_meta(repo, "feat/gone", intent="bye")
117 cp = config_toml_path(repo)
118 with cp.open("rb") as f:
119 before = tomllib.load(f)
120
121 delete_branch_meta(repo, "feat/gone")
122
123 with cp.open("rb") as f:
124 after = tomllib.load(f)
125 for key in before:
126 if key != "branch":
127 assert after.get(key) == before[key]
128
129 def test_noop_when_config_toml_absent(self, tmp_path: pathlib.Path) -> None:
130 """No config.toml at all — must not raise."""
131 delete_branch_meta(tmp_path, "feat/gone") # must not raise
132
133
134 # ---------------------------------------------------------------------------
135 # 2. Integration — muse branch -d / -D prunes config
136 # ---------------------------------------------------------------------------
137
138
139 class TestBranchDeletePrunesConfig:
140 def test_safe_delete_removes_config_section(self, repo: pathlib.Path) -> None:
141 _invoke(repo, ["checkout", "-b", "feat/thing"])
142 _invoke(repo, ["checkout", "main"])
143 write_branch_meta(repo, "feat/thing", intent="doing a thing", resumable=True)
144 assert _config_branch_section(repo, "feat/thing") != {}
145
146 _branch(repo, "-d", "feat/thing")
147
148 assert _config_branch_section(repo, "feat/thing") == {}
149
150 def test_force_delete_removes_config_section(self, repo: pathlib.Path) -> None:
151 _invoke(repo, ["checkout", "-b", "feat/unmerged"])
152 _commit(repo, "unmerged-work")
153 _invoke(repo, ["checkout", "main"])
154 write_branch_meta(repo, "feat/unmerged", intent="unmerged", resumable=True)
155
156 _branch(repo, "-D", "feat/unmerged")
157
158 assert _config_branch_section(repo, "feat/unmerged") == {}
159
160 def test_failed_delete_preserves_config_section(self, repo: pathlib.Path) -> None:
161 """Branch not merged — -d fails; config section must survive."""
162 _invoke(repo, ["checkout", "-b", "feat/unmerged"])
163 _commit(repo, "unmerged-work")
164 _invoke(repo, ["checkout", "main"])
165 write_branch_meta(repo, "feat/unmerged", intent="keep me")
166
167 result = _branch(repo, "-d", "feat/unmerged")
168 assert result.exit_code != 0
169
170 assert _config_branch_section(repo, "feat/unmerged") != {}
171
172 def test_delete_branch_with_no_config_section(self, repo: pathlib.Path) -> None:
173 """Deleting a branch that has no config entry must not crash."""
174 _invoke(repo, ["checkout", "-b", "feat/no-meta"])
175 _invoke(repo, ["checkout", "main"])
176 result = _branch(repo, "-d", "feat/no-meta")
177 assert result.exit_code == 0
178
179 def test_delete_multiple_branches_removes_all_sections(
180 self, repo: pathlib.Path
181 ) -> None:
182 _invoke(repo, ["checkout", "-b", "feat/a"])
183 _invoke(repo, ["checkout", "main"])
184 _invoke(repo, ["checkout", "-b", "feat/b"])
185 _invoke(repo, ["checkout", "main"])
186 write_branch_meta(repo, "feat/a", intent="a")
187 write_branch_meta(repo, "feat/b", intent="b")
188
189 _branch(repo, "-d", "feat/a", "feat/b")
190
191 assert _config_branch_section(repo, "feat/a") == {}
192 assert _config_branch_section(repo, "feat/b") == {}
193
194 def test_json_output_unaffected(self, repo: pathlib.Path) -> None:
195 """--json output shape is unchanged after the config cleanup."""
196 _invoke(repo, ["checkout", "-b", "feat/json-test"])
197 _invoke(repo, ["checkout", "main"])
198 write_branch_meta(repo, "feat/json-test", intent="test")
199
200 result = _branch(repo, "-d", "--json", "feat/json-test")
201
202 assert result.exit_code == 0
203 data = json.loads(result.output.strip())
204 assert data["action"] == "deleted"
205 assert data["branch"] == "feat/json-test"
File History 1 commit