gabriel / muse public
test_shelf_path_scoped.py python
241 lines 10.2 KB
Raw
sha256:a8de2a41b39b56b68a4f5024e8475ff463681093380c43b72ed39e256cf6a46c feat(shelf): --path scoped shelving, the native replacement… Sonnet 5 minor ⚠ breaking 1 day ago
1 """TDD — ``muse shelf save --path`` scoped shelving.
2
3 Makes ``muse shelf`` the idiomatic, native drop-in replacement for manually
4 copying files to /tmp as a backup before reverting an in-progress fix to
5 verify a test genuinely fails without it (and restoring afterward). Matches
6 ``git stash push -- <pathspec>`` semantics: only changes under the given
7 path(s) are shelved; everything else in the working tree — including
8 untracked files that don't match — is left completely untouched.
9 """
10 from __future__ import annotations
11
12 import json
13 import pathlib
14 from collections.abc import Mapping
15
16 import pytest
17
18 from tests.cli_test_helper import CliRunner
19
20 cli = None
21 runner = CliRunner()
22
23
24 def _env(root: pathlib.Path) -> Mapping[str, str]:
25 return {"MUSE_REPO_ROOT": str(root)}
26
27
28 @pytest.fixture()
29 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
30 """Repo with one committed file (fix_file.py)."""
31 monkeypatch.chdir(tmp_path)
32 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
33 r = runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
34 assert r.exit_code == 0, r.output
35 (tmp_path / "fix_file.py").write_text("original implementation\n")
36 r = runner.invoke(cli, ["commit", "-m", "base"], env=_env(tmp_path), catch_exceptions=False)
37 assert r.exit_code == 0, r.output
38 return tmp_path
39
40
41 class TestScopedSaveLeavesOtherFilesUntouched:
42 def test_modified_tracked_file_reverted_untracked_file_untouched(
43 self, repo: pathlib.Path
44 ) -> None:
45 """The exact RED/GREEN verification use case: a fix (modified
46 tracked file) plus a new test (untracked file). Scoping the shelf
47 to the fix must revert only the fix, leaving the new test on disk."""
48 (repo / "fix_file.py").write_text("FIXED implementation\n")
49 (repo / "new_test.py").write_text("new test content\n")
50
51 r = runner.invoke(
52 cli, ["shelf", "save", "--path", "fix_file.py", "--json"],
53 env=_env(repo), catch_exceptions=False,
54 )
55 assert r.exit_code == 0, r.output
56 data = json.loads(r.output)
57 assert data["status"] == "shelved"
58 assert data["paths"] == ["fix_file.py"]
59 assert data["files_count"] == 1
60
61 assert (repo / "fix_file.py").read_text() == "original implementation\n", (
62 "the fix must be reverted to HEAD"
63 )
64 assert (repo / "new_test.py").read_text() == "new test content\n", (
65 "the untracked test file must be completely untouched"
66 )
67
68 def test_unmatched_dirty_tracked_file_untouched(self, repo: pathlib.Path) -> None:
69 """A second tracked file with its own dirty, uncommitted edit, not
70 matching the pathspec, must also be left exactly as-is."""
71 (repo / "other.py").write_text("committed other\n")
72 runner.invoke(cli, ["code", "add", "."], env=_env(repo))
73 runner.invoke(cli, ["commit", "-m", "add other"], env=_env(repo))
74
75 (repo / "fix_file.py").write_text("FIXED implementation\n")
76 (repo / "other.py").write_text("unrelated dirty edit\n")
77
78 r = runner.invoke(
79 cli, ["shelf", "save", "--path", "fix_file.py", "--json"],
80 env=_env(repo), catch_exceptions=False,
81 )
82 assert r.exit_code == 0, r.output
83
84 assert (repo / "fix_file.py").read_text() == "original implementation\n"
85 assert (repo / "other.py").read_text() == "unrelated dirty edit\n", (
86 "an out-of-scope dirty tracked file must not be touched or reverted"
87 )
88
89 def test_pop_restores_only_the_scoped_fix(self, repo: pathlib.Path) -> None:
90 (repo / "fix_file.py").write_text("FIXED implementation\n")
91 (repo / "new_test.py").write_text("new test content\n")
92 runner.invoke(
93 cli, ["shelf", "save", "--path", "fix_file.py"], env=_env(repo),
94 )
95
96 r = runner.invoke(cli, ["shelf", "pop", "--json"], env=_env(repo), catch_exceptions=False)
97 assert r.exit_code == 0, r.output
98 data = json.loads(r.output)
99 assert data["restored"] == 1
100
101 assert (repo / "fix_file.py").read_text() == "FIXED implementation\n"
102 assert (repo / "new_test.py").read_text() == "new test content\n"
103
104
105 class TestScopedSaveNewlyAddedFile:
106 def test_newly_added_in_scope_file_removed_then_restored_on_pop(
107 self, repo: pathlib.Path
108 ) -> None:
109 (repo / "brand_new.py").write_text("brand new\n")
110 (repo / "unrelated.py").write_text("unrelated untracked\n")
111
112 r = runner.invoke(
113 cli, ["shelf", "save", "--path", "brand_new.py", "--json"],
114 env=_env(repo), catch_exceptions=False,
115 )
116 assert r.exit_code == 0, r.output
117 assert not (repo / "brand_new.py").exists(), (
118 "a newly-added in-scope file didn't exist in HEAD, so shelving "
119 "it must remove it from disk"
120 )
121 assert (repo / "unrelated.py").exists(), "unmatched untracked file must survive"
122
123 pop_r = runner.invoke(cli, ["shelf", "pop", "--json"], env=_env(repo), catch_exceptions=False)
124 assert pop_r.exit_code == 0, pop_r.output
125 assert (repo / "brand_new.py").read_text() == "brand new\n"
126
127
128 class TestScopedSaveDeletion:
129 def test_deleted_in_scope_file_restored_then_redeleted_on_pop(
130 self, repo: pathlib.Path
131 ) -> None:
132 (repo / "fix_file.py").unlink()
133
134 r = runner.invoke(
135 cli, ["shelf", "save", "--path", "fix_file.py", "--json"],
136 env=_env(repo), catch_exceptions=False,
137 )
138 assert r.exit_code == 0, r.output
139 assert (repo / "fix_file.py").read_text() == "original implementation\n", (
140 "shelving a deletion restores HEAD's content so the deletion "
141 "intent can be popped back later"
142 )
143
144 pop_r = runner.invoke(cli, ["shelf", "pop", "--json"], env=_env(repo), catch_exceptions=False)
145 assert pop_r.exit_code == 0, pop_r.output
146 pop_r_data = json.loads(pop_r.output)
147 assert pop_r_data["deleted"] == 1
148 assert not (repo / "fix_file.py").exists()
149
150
151 class TestScopedSaveDirectoryPrefix:
152 def test_directory_pathspec_matches_nested_files(self, repo: pathlib.Path) -> None:
153 (repo / "sub").mkdir()
154 (repo / "sub" / "nested.py").write_text("v1\n")
155 runner.invoke(cli, ["code", "add", "."], env=_env(repo))
156 runner.invoke(cli, ["commit", "-m", "add sub"], env=_env(repo))
157
158 (repo / "sub" / "nested.py").write_text("v2\n")
159 (repo / "top_level.py").write_text("unrelated untracked\n")
160
161 r = runner.invoke(
162 cli, ["shelf", "save", "--path", "sub", "--json"],
163 env=_env(repo), catch_exceptions=False,
164 )
165 assert r.exit_code == 0, r.output
166 assert (repo / "sub" / "nested.py").read_text() == "v1\n"
167 assert (repo / "top_level.py").read_text() == "unrelated untracked\n"
168
169
170 class TestScopedSaveNoMatch:
171 def test_no_changes_under_path_reports_nothing_to_shelf(
172 self, repo: pathlib.Path
173 ) -> None:
174 (repo / "untracked_elsewhere.py").write_text("dirty\n")
175
176 r = runner.invoke(
177 cli, ["shelf", "save", "--path", "fix_file.py", "--json"],
178 env=_env(repo), catch_exceptions=False,
179 )
180 assert r.exit_code == 0, r.output
181 data = json.loads(r.output)
182 assert data["status"] == "nothing_to_shelf"
183 assert data["paths"] == ["fix_file.py"]
184 # The out-of-scope dirty file must survive untouched.
185 assert (repo / "untracked_elsewhere.py").read_text() == "dirty\n"
186
187
188 class TestUnscopedBehaviorUnaffected:
189 """Regression guard: muse shelf save with no --path keeps its existing
190 whole-working-tree semantics exactly."""
191
192 def test_unscoped_save_still_shelves_everything(self, repo: pathlib.Path) -> None:
193 (repo / "fix_file.py").write_text("FIXED implementation\n")
194 (repo / "new_test.py").write_text("new test content\n")
195
196 r = runner.invoke(cli, ["shelf", "save", "--json"], env=_env(repo), catch_exceptions=False)
197 assert r.exit_code == 0, r.output
198 data = json.loads(r.output)
199 assert data["paths"] == []
200 assert (repo / "fix_file.py").read_text() == "original implementation\n"
201 assert not (repo / "new_test.py").exists(), (
202 "unscoped shelf save must still shelve untracked files too (unchanged behavior)"
203 )
204
205 def test_unscoped_pop_restores_everything(self, repo: pathlib.Path) -> None:
206 (repo / "fix_file.py").write_text("FIXED implementation\n")
207 (repo / "new_test.py").write_text("new test content\n")
208 runner.invoke(cli, ["shelf", "save"], env=_env(repo))
209
210 r = runner.invoke(cli, ["shelf", "pop", "--json"], env=_env(repo), catch_exceptions=False)
211 assert r.exit_code == 0, r.output
212 assert (repo / "fix_file.py").read_text() == "FIXED implementation\n"
213 assert (repo / "new_test.py").read_text() == "new test content\n"
214
215
216 class TestListAndReadExposePaths:
217 def test_list_json_includes_paths_field(self, repo: pathlib.Path) -> None:
218 (repo / "fix_file.py").write_text("FIXED implementation\n")
219 runner.invoke(cli, ["shelf", "save", "--path", "fix_file.py"], env=_env(repo))
220
221 r = runner.invoke(cli, ["shelf", "list", "--json"], env=_env(repo), catch_exceptions=False)
222 assert r.exit_code == 0, r.output
223 data = json.loads(r.output)
224 assert data["entries"][0]["paths"] == ["fix_file.py"]
225
226 def test_read_json_includes_paths_field(self, repo: pathlib.Path) -> None:
227 (repo / "fix_file.py").write_text("FIXED implementation\n")
228 runner.invoke(cli, ["shelf", "save", "--path", "fix_file.py"], env=_env(repo))
229
230 r = runner.invoke(cli, ["shelf", "read", "--json"], env=_env(repo), catch_exceptions=False)
231 assert r.exit_code == 0, r.output
232 data = json.loads(r.output)
233 assert data["paths"] == ["fix_file.py"]
234
235 def test_unscoped_entry_paths_field_is_empty_list(self, repo: pathlib.Path) -> None:
236 (repo / "fix_file.py").write_text("FIXED implementation\n")
237 runner.invoke(cli, ["shelf", "save"], env=_env(repo))
238
239 r = runner.invoke(cli, ["shelf", "list", "--json"], env=_env(repo), catch_exceptions=False)
240 data = json.loads(r.output)
241 assert data["entries"][0]["paths"] == []
File History 1 commit
sha256:a8de2a41b39b56b68a4f5024e8475ff463681093380c43b72ed39e256cf6a46c feat(shelf): --path scoped shelving, the native replacement… Sonnet 5 minor ⚠ 1 day ago