gabriel / muse public
test_checkout_autostash.py python
394 lines 20.4 KB
Raw
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
1 """Tests for ``muse checkout --autostash``.
2
3 Coverage matrix
4 ---------------
5 Unit
6 - ``_stash_push_programmatic`` / ``_stash_pop_programmatic`` in isolation
7 - ``_apply_autostash`` text vs JSON mode output
8
9 Integration (full CLI round-trips via CliRunner)
10 - clean tree: --autostash is a no-op (no stash entry created)
11 - dirty tree: stash pushed, branch switched, stash popped
12 - dirty tree, --json mode: correct JSON on stdout; autostash lines on stderr
13 - stash pop restores modified file content on the new branch
14 - stash pop restores newly-added files that don't exist in target branch
15 - ``-b`` + ``--autostash``: new-branch creation ignores autostash (no push)
16 - ``--autostash`` + ``--force`` is rejected
17 - ``--autostash`` + ``--merge`` is rejected
18 - ``--autostash`` + ``--dry-run``: no stash created; exit 0 if branch exists
19 - already-on same branch: autostash no-op, no stash entry
20 - guard.py error message includes all four remedies when dirty without flags
21 - pop failure (corrupt stash): warns to stderr, does NOT re-raise
22
23 The suite uses only stdlib + pytest; no mocking of internals. Every test
24 operates against a real, hermetic Muse repo in tmp_path.
25 """
26
27 from __future__ import annotations
28
29 import io
30 import json
31 import os
32 import pathlib
33 import sys
34 import contextlib
35
36 import pytest
37
38 from tests.cli_test_helper import CliRunner, InvokeResult
39 from muse.core.store import read_current_branch
40 from muse.cli.commands.stash import (
41 _stash_push_programmatic,
42 _stash_pop_programmatic,
43 _load_stash,
44 )
45 from muse.cli.commands.checkout import _apply_autostash
46
47 runner = CliRunner()
48
49
50 # ── Helpers ───────────────────────────────────────────────────────────────────
51
52
53 def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult:
54 saved = os.getcwd()
55 try:
56 os.chdir(repo)
57 return runner.invoke(None, args)
58 finally:
59 os.chdir(saved)
60
61
62 def _commit(repo: pathlib.Path, files: dict[str, str], msg: str = "commit") -> None:
63 for name, content in files.items():
64 (repo / name).write_text(content, encoding="utf-8")
65 _invoke(repo, ["commit", "-m", msg])
66
67
68 def _stash_size(repo: pathlib.Path) -> int:
69 return len(_load_stash(repo))
70
71
72 # ── Fixtures ──────────────────────────────────────────────────────────────────
73
74
75 @pytest.fixture()
76 def two_branch_repo(tmp_path: pathlib.Path) -> pathlib.Path:
77 """Repo with ``main`` and ``feat`` branches with distinct committed files.
78
79 main: a.py="main\\n"
80 feat: a.py="main\\n", b.py="feat\\n"
81 """
82 saved = os.getcwd()
83 try:
84 os.chdir(tmp_path)
85 runner.invoke(None, ["init"])
86 finally:
87 os.chdir(saved)
88
89 _commit(tmp_path, {"a.py": "main\n"}, "main: initial")
90 _invoke(tmp_path, ["checkout", "-b", "feat"])
91 _commit(tmp_path, {"b.py": "feat\n"}, "feat: add b.py")
92 _invoke(tmp_path, ["checkout", "main"])
93 return tmp_path
94
95
96 # ── Unit: _stash_push_programmatic / _stash_pop_programmatic ─────────────────
97
98
99 class TestProgrammaticStashAPI:
100 """``_stash_push_programmatic`` and ``_stash_pop_programmatic`` are the
101 building blocks for autostash — they must work correctly in isolation."""
102
103 def test_push_returns_none_when_clean(self, two_branch_repo: pathlib.Path) -> None:
104 """Nothing to stash → None returned, stash stack unchanged."""
105 result = _stash_push_programmatic(two_branch_repo)
106 assert result is None
107 assert _stash_size(two_branch_repo) == 0
108
109 def test_push_returns_entry_when_dirty(self, two_branch_repo: pathlib.Path) -> None:
110 """Modified file → entry returned with correct metadata."""
111 (two_branch_repo / "a.py").write_text("modified\n", encoding="utf-8")
112 entry = _stash_push_programmatic(two_branch_repo, message="wip")
113 assert entry is not None
114 assert entry["message"] == "wip"
115 assert entry["branch"] == "main"
116 assert "a.py" in entry["delta"]
117 assert _stash_size(two_branch_repo) == 1
118
119 def test_push_restores_working_tree_to_head(self, two_branch_repo: pathlib.Path) -> None:
120 """After push, working tree matches HEAD (file reverted to committed state)."""
121 (two_branch_repo / "a.py").write_text("modified\n", encoding="utf-8")
122 _stash_push_programmatic(two_branch_repo)
123 assert (two_branch_repo / "a.py").read_text(encoding="utf-8") == "main\n"
124
125 def test_push_captures_added_files(self, two_branch_repo: pathlib.Path) -> None:
126 """Newly-added files (not in HEAD) are captured in the stash delta."""
127 (two_branch_repo / "new.py").write_text("new\n", encoding="utf-8")
128 # new.py is untracked — push captures it only if the plugin sees it.
129 # In muse, snapshot includes all non-ignored files, so it IS in delta.
130 entry = _stash_push_programmatic(two_branch_repo)
131 if entry is not None:
132 # If captured, it must be in delta
133 assert "new.py" in entry["delta"]
134
135 def test_pop_raises_on_empty_stash(self, two_branch_repo: pathlib.Path) -> None:
136 with pytest.raises(ValueError, match="No stash entries"):
137 _stash_pop_programmatic(two_branch_repo)
138
139 def test_pop_raises_on_out_of_range_index(self, two_branch_repo: pathlib.Path) -> None:
140 (two_branch_repo / "a.py").write_text("v2\n", encoding="utf-8")
141 _stash_push_programmatic(two_branch_repo)
142 with pytest.raises(ValueError, match="out of range"):
143 _stash_pop_programmatic(two_branch_repo, index=5)
144
145 def test_pop_restores_file_content(self, two_branch_repo: pathlib.Path) -> None:
146 """Pop restores the exact bytes that were stashed."""
147 (two_branch_repo / "a.py").write_text("dirty\n", encoding="utf-8")
148 _stash_push_programmatic(two_branch_repo)
149 assert (two_branch_repo / "a.py").read_text(encoding="utf-8") == "main\n"
150 _stash_pop_programmatic(two_branch_repo)
151 assert (two_branch_repo / "a.py").read_text(encoding="utf-8") == "dirty\n"
152
153 def test_pop_removes_entry_from_stack(self, two_branch_repo: pathlib.Path) -> None:
154 (two_branch_repo / "a.py").write_text("dirty\n", encoding="utf-8")
155 _stash_push_programmatic(two_branch_repo)
156 assert _stash_size(two_branch_repo) == 1
157 _stash_pop_programmatic(two_branch_repo)
158 assert _stash_size(two_branch_repo) == 0
159
160 def test_push_pop_roundtrip_message_preserved(self, two_branch_repo: pathlib.Path) -> None:
161 (two_branch_repo / "a.py").write_text("v3\n", encoding="utf-8")
162 _stash_push_programmatic(two_branch_repo, message="WIP on main: autostash")
163 entry = _stash_pop_programmatic(two_branch_repo)
164 assert entry["message"] == "WIP on main: autostash"
165
166
167 # ── Unit: _apply_autostash output ────────────────────────────────────────────
168
169
170 class TestApplyAutostash:
171 """``_apply_autostash`` must emit the right text and use the right streams."""
172
173 def test_text_mode_prints_to_stdout(self, two_branch_repo: pathlib.Path) -> None:
174 (two_branch_repo / "a.py").write_text("dirty\n", encoding="utf-8")
175 _stash_push_programmatic(two_branch_repo)
176 out = io.StringIO()
177 with contextlib.redirect_stdout(out):
178 _apply_autostash(two_branch_repo, "text")
179 assert "Applied autostash" in out.getvalue()
180
181 def test_json_mode_prints_to_stderr(self, two_branch_repo: pathlib.Path) -> None:
182 (two_branch_repo / "a.py").write_text("dirty\n", encoding="utf-8")
183 _stash_push_programmatic(two_branch_repo)
184 out = io.StringIO()
185 err = io.StringIO()
186 with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
187 _apply_autostash(two_branch_repo, "json")
188 assert out.getvalue() == ""
189 assert "autostash" in err.getvalue()
190
191 def test_corrupt_stash_warns_stderr_does_not_raise(self, two_branch_repo: pathlib.Path) -> None:
192 """Missing objects → warning on stderr, no exception propagation."""
193 (two_branch_repo / "a.py").write_text("dirty\n", encoding="utf-8")
194 entry = _stash_push_programmatic(two_branch_repo)
195 assert entry is not None
196 # Corrupt the object store by zeroing the object file for a.py
197 obj_id = entry["delta"]["a.py"]
198 obj_path = two_branch_repo / ".muse" / "objects" / obj_id[:2] / obj_id[2:]
199 if obj_path.exists():
200 obj_path.chmod(0o644)
201 obj_path.write_bytes(b"") # zero it out → read_object returns None
202 err = io.StringIO()
203 with contextlib.redirect_stderr(err):
204 _apply_autostash(two_branch_repo, "text") # must not raise
205 assert "stash@{0}" in err.getvalue() or "autostash" in err.getvalue()
206
207
208 # ── Integration: --autostash CLI end-to-end ───────────────────────────────────
209
210
211 class TestAutostashCLI:
212 """Full CLI round-trips through CliRunner."""
213
214 # ── clean tree ────────────────────────────────────────────────────────────
215
216 def test_clean_tree_no_stash_entry_created(self, two_branch_repo: pathlib.Path) -> None:
217 """--autostash on a clean tree → switch succeeds, stash stack stays empty."""
218 result = _invoke(two_branch_repo, ["checkout", "--autostash", "feat"])
219 assert result.exit_code == 0
220 assert read_current_branch(two_branch_repo) == "feat"
221 assert _stash_size(two_branch_repo) == 0
222
223 def test_clean_tree_no_autostash_message(self, two_branch_repo: pathlib.Path) -> None:
224 """No stash push message printed when tree is already clean."""
225 result = _invoke(two_branch_repo, ["checkout", "--autostash", "feat"])
226 assert "Stashing" not in result.output
227 assert "Applied autostash" not in result.output
228
229 # ── dirty tree ────────────────────────────────────────────────────────────
230
231 def test_dirty_tree_switch_succeeds(self, two_branch_repo: pathlib.Path) -> None:
232 """Dirty tracked file does not block checkout when --autostash is used."""
233 (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8")
234 result = _invoke(two_branch_repo, ["checkout", "--autostash", "feat"])
235 assert result.exit_code == 0, result.stderr
236 assert read_current_branch(two_branch_repo) == "feat"
237
238 def test_dirty_tree_stash_popped_after_switch(self, two_branch_repo: pathlib.Path) -> None:
239 """After the switch, stash stack is empty (pop happened)."""
240 (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8")
241 _invoke(two_branch_repo, ["checkout", "--autostash", "feat"])
242 assert _stash_size(two_branch_repo) == 0
243
244 def test_dirty_tree_changes_restored_on_new_branch(self, two_branch_repo: pathlib.Path) -> None:
245 """Stashed changes are visible in the working tree after arriving on feat."""
246 (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8")
247 _invoke(two_branch_repo, ["checkout", "--autostash", "feat"])
248 assert (two_branch_repo / "a.py").read_text(encoding="utf-8") == "wip\n"
249
250 def test_text_mode_prints_stash_then_applied(self, two_branch_repo: pathlib.Path) -> None:
251 """Text mode: 'Stashing…' before switch, 'Applied autostash.' after."""
252 (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8")
253 result = _invoke(two_branch_repo, ["checkout", "--autostash", "feat"])
254 assert "Stashing" in result.output
255 assert "Applied autostash" in result.output
256
257 # ── JSON mode ─────────────────────────────────────────────────────────────
258
259 def test_json_mode_stdout_is_valid_json(self, two_branch_repo: pathlib.Path) -> None:
260 """In --json mode, stdout must be parseable JSON (no autostash noise)."""
261 (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8")
262 result = _invoke(two_branch_repo, ["checkout", "--autostash", "--json", "feat"])
263 assert result.exit_code == 0, result.stderr
264 # stdout has two lines: checkout JSON + possibly "Stashing..." but
265 # Stashing goes to stdout in text mode. In JSON mode it must be
266 # parseable — find the JSON line.
267 lines = [l for l in result.output.strip().splitlines() if l.strip().startswith("{")]
268 assert len(lines) >= 1
269 payload = json.loads(lines[0])
270 assert payload["action"] == "switched"
271 assert payload["branch"] == "feat"
272
273 def test_json_mode_applied_autostash_goes_to_stderr(self, two_branch_repo: pathlib.Path) -> None:
274 """'Applied autostash' must not appear on stdout in JSON mode."""
275 (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8")
276 result = _invoke(two_branch_repo, ["checkout", "--autostash", "--json", "feat"])
277 assert "Applied autostash" not in result.output
278
279 # ── new file survives switch ──────────────────────────────────────────────
280
281 def test_new_file_on_main_survives_to_feat(self, two_branch_repo: pathlib.Path) -> None:
282 """A file added only on main (not committed) is stashed and popped onto feat."""
283 (two_branch_repo / "wip.py").write_text("todo\n", encoding="utf-8")
284 result = _invoke(two_branch_repo, ["checkout", "--autostash", "feat"])
285 assert result.exit_code == 0, result.stderr
286 # wip.py should be restored on feat (it was in the stash delta)
287 assert (two_branch_repo / "wip.py").read_text(encoding="utf-8") == "todo\n"
288
289 # ── mutually exclusive flags ──────────────────────────────────────────────
290
291 def test_autostash_and_force_rejected(self, two_branch_repo: pathlib.Path) -> None:
292 result = _invoke(two_branch_repo, ["checkout", "--autostash", "--force", "feat"])
293 assert result.exit_code != 0
294 assert "mutually exclusive" in (result.stderr or result.output).lower()
295
296 def test_autostash_and_merge_rejected(self, two_branch_repo: pathlib.Path) -> None:
297 result = _invoke(two_branch_repo, ["checkout", "--autostash", "--merge", "feat"])
298 assert result.exit_code != 0
299 assert "mutually exclusive" in (result.stderr or result.output).lower()
300
301 # ── --dry-run + --autostash ────────────────────────────────────────────────
302
303 def test_dry_run_no_stash_created(self, two_branch_repo: pathlib.Path) -> None:
304 """--dry-run never actually stashes — it just previews."""
305 (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8")
306 _invoke(two_branch_repo, ["checkout", "--autostash", "--dry-run", "feat"])
307 assert _stash_size(two_branch_repo) == 0
308
309 def test_dry_run_no_branch_switch(self, two_branch_repo: pathlib.Path) -> None:
310 """--dry-run + --autostash must not change the current branch."""
311 (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8")
312 _invoke(two_branch_repo, ["checkout", "--autostash", "--dry-run", "feat"])
313 assert read_current_branch(two_branch_repo) == "main"
314
315 def test_dry_run_working_tree_unchanged(self, two_branch_repo: pathlib.Path) -> None:
316 """--dry-run must not touch the working tree at all."""
317 (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8")
318 _invoke(two_branch_repo, ["checkout", "--autostash", "--dry-run", "feat"])
319 assert (two_branch_repo / "a.py").read_text(encoding="utf-8") == "wip\n"
320
321 def test_dry_run_exits_zero_on_existing_branch(self, two_branch_repo: pathlib.Path) -> None:
322 result = _invoke(two_branch_repo, ["checkout", "--autostash", "--dry-run", "feat"])
323 assert result.exit_code == 0
324
325 # ── -b + --autostash ──────────────────────────────────────────────────────
326
327 def test_create_branch_autostash_is_noop(self, two_branch_repo: pathlib.Path) -> None:
328 """``-b`` stays at HEAD — no snapshot switch, so no stash needed."""
329 (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8")
330 result = _invoke(two_branch_repo, ["checkout", "--autostash", "-b", "experiment"])
331 assert result.exit_code == 0, result.stderr
332 assert read_current_branch(two_branch_repo) == "experiment"
333 # Changes preserved (no stash push on -b)
334 assert (two_branch_repo / "a.py").read_text(encoding="utf-8") == "wip\n"
335 assert _stash_size(two_branch_repo) == 0
336
337 # ── already on target branch ──────────────────────────────────────────────
338
339 def test_already_on_branch_no_stash(self, two_branch_repo: pathlib.Path) -> None:
340 """Checking out the current branch is a no-op; autostash not triggered."""
341 result = _invoke(two_branch_repo, ["checkout", "--autostash", "main"])
342 assert result.exit_code == 0
343 assert _stash_size(two_branch_repo) == 0
344
345 # ── error message (no --autostash) ───────────────────────────────────────
346
347 def test_dirty_without_autostash_shows_all_remedies(self, two_branch_repo: pathlib.Path) -> None:
348 """guard.py error lists muse stash, --merge, --force, and --autostash."""
349 (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8")
350 result = _invoke(two_branch_repo, ["checkout", "feat"])
351 assert result.exit_code != 0
352 err = result.stderr or ""
353 assert "muse stash" in err
354 assert "--merge" in err
355 assert "--force" in err
356 assert "--autostash" in err
357
358 # ── stash message format ──────────────────────────────────────────────────
359
360 def test_autostash_message_matches_git_idiom(self, two_branch_repo: pathlib.Path) -> None:
361 """Stash message follows git's 'WIP on <branch>: autostash' format."""
362 (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8")
363
364 # Intercept before pop by pushing manually and inspecting
365 entry = _stash_push_programmatic(
366 two_branch_repo,
367 message=f"WIP on main: autostash",
368 )
369 assert entry is not None
370 assert entry["message"].startswith("WIP on main")
371 # Clean up
372 _stash_pop_programmatic(two_branch_repo)
373
374 # ── round-trip fidelity ───────────────────────────────────────────────────
375
376 def test_multiple_dirty_files_all_restored(self, two_branch_repo: pathlib.Path) -> None:
377 """All modified files are stashed and fully restored after the switch."""
378 (two_branch_repo / "a.py").write_text("a-wip\n", encoding="utf-8")
379 (two_branch_repo / "c.py").write_text("new-file\n", encoding="utf-8")
380 _invoke(two_branch_repo, ["checkout", "--autostash", "feat"])
381 assert (two_branch_repo / "a.py").read_text(encoding="utf-8") == "a-wip\n"
382 assert (two_branch_repo / "c.py").read_text(encoding="utf-8") == "new-file\n"
383 assert _stash_size(two_branch_repo) == 0
384
385 def test_switch_back_restores_original_state(self, two_branch_repo: pathlib.Path) -> None:
386 """Round-trip main→feat→main with autostash; a.py ends at the wip value."""
387 (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8")
388 _invoke(two_branch_repo, ["checkout", "--autostash", "feat"])
389 assert read_current_branch(two_branch_repo) == "feat"
390 # Switch back
391 _invoke(two_branch_repo, ["checkout", "--autostash", "main"])
392 assert read_current_branch(two_branch_repo) == "main"
393 # wip was re-stashed on feat and re-popped on main
394 assert (two_branch_repo / "a.py").read_text(encoding="utf-8") == "wip\n"
File History 1 commit
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago