gabriel / muse public
test_cli_coverage_gaps.py python
337 lines 13.8 KB
Raw
1 """Tests targeting specific coverage gaps in checkout, tag, commit, diff, shelf, branch."""
2
3 import pathlib
4
5 import pytest
6 from tests.cli_test_helper import CliRunner
7
8 cli = None # argparse migration — CliRunner ignores this arg
9 from muse.core.refs import get_head_commit_id
10
11 runner = CliRunner()
12
13
14 @pytest.fixture
15 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
16 monkeypatch.chdir(tmp_path)
17 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
18 result = runner.invoke(cli, ["init"])
19 assert result.exit_code == 0, result.output
20 return tmp_path
21
22
23 def _write(repo: pathlib.Path, filename: str, content: str = "data") -> None:
24 (repo / filename).write_text(content)
25
26
27 def _commit(msg: str = "commit") -> str | None:
28 runner.invoke(cli, ["code", "add", "."])
29 result = runner.invoke(cli, ["commit", "-m", msg])
30 assert result.exit_code == 0, result.output
31 return get_head_commit_id(pathlib.Path("."), "main")
32
33
34 # ---------------------------------------------------------------------------
35 # checkout gaps
36 # ---------------------------------------------------------------------------
37
38
39 class TestCheckoutGaps:
40 def test_create_branch_already_exists_errors(self, repo: pathlib.Path) -> None:
41 result = runner.invoke(cli, ["checkout", "-b", "main"])
42 assert result.exit_code != 0
43 assert "already exists" in result.stderr
44
45 def test_unknown_ref_errors(self, repo: pathlib.Path) -> None:
46 result = runner.invoke(cli, ["checkout", "no-such-branch-or-commit"])
47 assert result.exit_code != 0
48 assert "not a branch" in result.stderr.lower() or "not found" in result.stderr.lower() or "no-such" in result.stderr
49
50 def test_checkout_by_commit_id_detaches_head(self, repo: pathlib.Path) -> None:
51 _write(repo, "beat.py")
52 runner.invoke(cli, ["code", "add", "."])
53 result = runner.invoke(cli, ["commit", "-m", "root"])
54 assert result.exit_code == 0
55 commit_id = get_head_commit_id(repo, "main")
56 assert commit_id is not None
57
58 _write(repo, "lead.py")
59 runner.invoke(cli, ["code", "add", "."])
60 runner.invoke(cli, ["commit", "-m", "second"])
61
62 result = runner.invoke(cli, ["checkout", commit_id])
63 assert result.exit_code == 0
64 assert "HEAD is now at" in result.output
65
66 def test_checkout_restores_workdir_to_target_snapshot(self, repo: pathlib.Path) -> None:
67 _write(repo, "beat.py", "v1")
68 runner.invoke(cli, ["code", "add", "."])
69 result = runner.invoke(cli, ["commit", "-m", "first"])
70 assert result.exit_code == 0
71 first_id = get_head_commit_id(repo, "main")
72 assert first_id is not None
73
74 _write(repo, "lead.py", "new")
75 runner.invoke(cli, ["code", "add", "."])
76 runner.invoke(cli, ["commit", "-m", "second"])
77
78 runner.invoke(cli, ["checkout", first_id])
79 assert (repo / "beat.py").exists()
80 assert not (repo / "lead.py").exists()
81
82
83 # ---------------------------------------------------------------------------
84 # tag gaps
85 # ---------------------------------------------------------------------------
86
87
88 class TestTagGaps:
89 def test_tag_add_unknown_ref_errors(self, repo: pathlib.Path) -> None:
90 """A valid SemVer tag name against an unresolvable ref must still error.
91
92 Previously used 'emotion:joyful' as the tag name, which coincidentally
93 still failed post-SemVer-formalization — but for the wrong reason (bad
94 tag name, not bad ref). Using a valid tag name isolates the actual ref
95 resolution error this test is meant to cover.
96 """
97 _write(repo, "beat.py")
98 runner.invoke(cli, ["code", "add", "."])
99 runner.invoke(cli, ["commit", "-m", "base"])
100 result = runner.invoke(cli, ["tag", "add", "v1.0.0", "deadbeef"])
101 assert result.exit_code != 0
102
103 def test_tag_list_for_specific_commit(self, repo: pathlib.Path) -> None:
104 """Commit-scoped free-form annotations live under `label`, not `tag`.
105
106 `muse tag` was formalized to strict SemVer version tags; the
107 old free-form namespace:value use case (`emotion:joyful`) moved to
108 `muse label`, which is what actually supports an optional ref arg.
109 """
110 _write(repo, "beat.py")
111 runner.invoke(cli, ["code", "add", "."])
112 runner.invoke(cli, ["commit", "-m", "base"])
113 commit_id = get_head_commit_id(repo, "main")
114 assert commit_id is not None
115
116 short = commit_id.removeprefix("sha256:")[:8]
117 runner.invoke(cli, ["label", "add", "emotion:joyful", short])
118 result = runner.invoke(cli, ["label", "list", short])
119 assert result.exit_code == 0
120 assert "joyful" in result.output
121
122 def test_tag_list_for_unknown_ref_errors(self, repo: pathlib.Path) -> None:
123 """`muse tag list` no longer takes a positional ref at all (version
124 tags are a global namespace, not commit-scoped) — the old assertion
125 passed only because argparse rejects the extra arg with exit 2,
126 which happens to also be non-zero. The ref-scoped list command is
127 now `label list`, which is what actually resolves and can error on
128 an unknown ref.
129 """
130 _write(repo, "beat.py")
131 runner.invoke(cli, ["code", "add", "."])
132 runner.invoke(cli, ["commit", "-m", "base"])
133 result = runner.invoke(cli, ["label", "list", "deadbeef"])
134 assert result.exit_code != 0
135
136 def test_tag_list_all_shows_all_tags(self, repo: pathlib.Path) -> None:
137 """Same rename as above: free-form namespace:value strings are labels."""
138 _write(repo, "beat.py")
139 runner.invoke(cli, ["code", "add", "."])
140 runner.invoke(cli, ["commit", "-m", "base"])
141 commit_id = get_head_commit_id(repo, "main")
142 assert commit_id is not None
143
144 short = commit_id.removeprefix("sha256:")[:8]
145 runner.invoke(cli, ["label", "add", "section:verse", short])
146 runner.invoke(cli, ["label", "add", "emotion:joyful", short])
147 result = runner.invoke(cli, ["label", "list"])
148 assert result.exit_code == 0
149 assert "section:verse" in result.output
150 assert "emotion:joyful" in result.output
151
152
153 # ---------------------------------------------------------------------------
154 # commit gaps
155 # ---------------------------------------------------------------------------
156
157
158 class TestCommitGaps:
159 def test_no_message_without_allow_empty_errors(self, repo: pathlib.Path) -> None:
160 _write(repo, "beat.py")
161 runner.invoke(cli, ["code", "add", "."])
162 result = runner.invoke(cli, ["commit"])
163 assert result.exit_code != 0
164
165 def test_empty_repo_without_allow_empty_errors(self, repo: pathlib.Path) -> None:
166 # muse init creates .museignore / .museattributes which are tracked by default.
167 # Commit that initial state, then verify a second commit on a clean tree
168 # reports nothing to commit.
169 runner.invoke(cli, ["code", "add", "."])
170 runner.invoke(cli, ["commit", "-m", "init files"])
171 runner.invoke(cli, ["code", "add", "."])
172 result = runner.invoke(cli, ["commit", "-m", "nothing here"])
173 assert result.exit_code == 0
174 assert "Nothing to commit" in result.output or "nothing" in result.output.lower()
175
176 def test_empty_workdir_without_allow_empty_errors(self, repo: pathlib.Path) -> None:
177 # Same as above: commit init-created dotfiles first, then verify clean tree.
178 runner.invoke(cli, ["code", "add", "."])
179 runner.invoke(cli, ["commit", "-m", "init files"])
180 runner.invoke(cli, ["code", "add", "."])
181 result = runner.invoke(cli, ["commit", "-m", "empty"])
182 assert result.exit_code == 0
183 assert "Nothing to commit" in result.output or "nothing" in result.output.lower()
184
185 def test_nothing_to_commit_clean_tree(self, repo: pathlib.Path) -> None:
186 _write(repo, "beat.py")
187 runner.invoke(cli, ["code", "add", "."])
188 runner.invoke(cli, ["commit", "-m", "first"])
189 runner.invoke(cli, ["code", "add", "."])
190 result = runner.invoke(cli, ["commit", "-m", "second"])
191 assert result.exit_code == 0
192 assert "Nothing to commit" in result.output or "clean" in result.output
193
194 def test_commit_with_pending_conflicts_errors(self, repo: pathlib.Path) -> None:
195 _write(repo, "shared.py", "def fn():\n return 1\n")
196 runner.invoke(cli, ["code", "add", "."])
197 runner.invoke(cli, ["commit", "-m", "base"])
198
199 runner.invoke(cli, ["branch", "feature"])
200 runner.invoke(cli, ["checkout", "feature"])
201 _write(repo, "shared.py", "def fn():\n return 2\n")
202 runner.invoke(cli, ["code", "add", "."])
203 runner.invoke(cli, ["commit", "-m", "feature changes"])
204
205 runner.invoke(cli, ["checkout", "main"])
206 _write(repo, "shared.py", "def fn():\n return 3\n")
207 runner.invoke(cli, ["code", "add", "."])
208 runner.invoke(cli, ["commit", "-m", "main changes"])
209
210 runner.invoke(cli, ["merge", "feature"])
211
212 # Now try to commit — should fail because of unresolved conflicts
213 _write(repo, "new.py", "x = 1\n")
214 runner.invoke(cli, ["code", "add", "."])
215 result = runner.invoke(cli, ["commit", "-m", "during conflict"])
216 assert result.exit_code != 0
217 assert "conflict" in result.stderr.lower()
218
219
220 # ---------------------------------------------------------------------------
221 # diff gaps
222 # ---------------------------------------------------------------------------
223
224
225 class TestDiffGaps:
226 def test_diff_two_commits(self, repo: pathlib.Path) -> None:
227 _write(repo, "a.py", "v1")
228 runner.invoke(cli, ["code", "add", "."])
229 runner.invoke(cli, ["commit", "-m", "first"])
230 first_id = get_head_commit_id(repo, "main")
231
232 _write(repo, "a.py", "v2")
233 runner.invoke(cli, ["code", "add", "."])
234 runner.invoke(cli, ["commit", "-m", "second"])
235 second_id = get_head_commit_id(repo, "main")
236
237 assert first_id is not None
238 assert second_id is not None
239 result = runner.invoke(cli, ["diff", first_id, second_id])
240 assert result.exit_code == 0
241 assert "a.py" in result.output
242
243 def test_diff_commit_vs_head(self, repo: pathlib.Path) -> None:
244 _write(repo, "a.py", "v1")
245 runner.invoke(cli, ["code", "add", "."])
246 runner.invoke(cli, ["commit", "-m", "first"])
247 first_id = get_head_commit_id(repo, "main")
248
249 _write(repo, "a.py", "v2")
250 runner.invoke(cli, ["code", "add", "."])
251 runner.invoke(cli, ["commit", "-m", "second"])
252 assert first_id is not None
253
254 result = runner.invoke(cli, ["diff", first_id])
255 assert result.exit_code == 0
256 assert "a.py" in result.output
257
258 def test_diff_stat_flag(self, repo: pathlib.Path) -> None:
259 _write(repo, "a.py")
260 runner.invoke(cli, ["code", "add", "."])
261 runner.invoke(cli, ["commit", "-m", "base"])
262 _write(repo, "b.py")
263 result = runner.invoke(cli, ["diff", "--stat"])
264 assert result.exit_code == 0
265 # --stat prints the structured delta summary line.
266 assert "added" in result.output or "No differences" in result.output
267
268
269 # ---------------------------------------------------------------------------
270 # shelf gaps
271 # ---------------------------------------------------------------------------
272
273
274 class TestShelfGaps:
275 def test_shelf_pop_restores_files(self, repo: pathlib.Path) -> None:
276 _write(repo, "beat.py")
277 runner.invoke(cli, ["code", "add", "."])
278 runner.invoke(cli, ["commit", "-m", "base"])
279 _write(repo, "unsaved.py", "wip")
280
281 runner.invoke(cli, ["shelf", "save"])
282 assert not (repo / "unsaved.py").exists()
283
284 runner.invoke(cli, ["shelf", "pop"])
285 assert (repo / "unsaved.py").exists()
286
287 def test_shelf_drop_removes_entry(self, repo: pathlib.Path) -> None:
288 _write(repo, "beat.py")
289 runner.invoke(cli, ["code", "add", "."])
290 runner.invoke(cli, ["commit", "-m", "base"])
291 _write(repo, "unsaved.py", "wip")
292
293 runner.invoke(cli, ["shelf", "save"])
294 result = runner.invoke(cli, ["shelf", "drop"])
295 assert result.exit_code == 0
296
297 result = runner.invoke(cli, ["shelf", "list"])
298 assert "No shelf entries" in result.output
299
300 def test_shelf_pop_empty_errors(self, repo: pathlib.Path) -> None:
301 result = runner.invoke(cli, ["shelf", "pop"])
302 assert result.exit_code != 0
303
304 def test_shelf_drop_empty_errors(self, repo: pathlib.Path) -> None:
305 result = runner.invoke(cli, ["shelf", "drop"])
306 assert result.exit_code != 0
307
308
309
310 # ---------------------------------------------------------------------------
311 # branch gaps
312 # ---------------------------------------------------------------------------
313
314
315 class TestBranchGaps:
316 def test_delete_branch_with_d_flag(self, repo: pathlib.Path) -> None:
317 runner.invoke(cli, ["branch", "to-delete"])
318 result = runner.invoke(cli, ["branch", "-D", "to-delete"])
319 assert result.exit_code == 0
320
321 def test_delete_current_branch_errors(self, repo: pathlib.Path) -> None:
322 result = runner.invoke(cli, ["branch", "-d", "main"])
323 assert result.exit_code != 0
324 assert "current" in result.stderr.lower() or "main" in result.stderr
325
326 def test_delete_nonexistent_branch_errors(self, repo: pathlib.Path) -> None:
327 result = runner.invoke(cli, ["branch", "-d", "no-such-branch"])
328 assert result.exit_code != 0
329
330 def test_create_branch_with_b_flag(self, repo: pathlib.Path) -> None:
331 result = runner.invoke(cli, ["branch", "new-feature"])
332 assert result.exit_code == 0
333
334 def test_branch_with_slash_shown_in_list(self, repo: pathlib.Path) -> None:
335 runner.invoke(cli, ["branch", "feature/my-thing"])
336 result = runner.invoke(cli, ["branch"])
337 assert "feature/my-thing" in result.output
File History 1 commit