cgcardona / muse public
test_stash.py python
424 lines 14.8 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Tests for ``muse stash`` — full lifecycle and edge cases.
2
3 Exercises:
4 - ``test_stash_push_pop_roundtrip`` — regression: push saves state, pop restores it
5 - ``test_stash_push_clears_workdir`` — full push restores HEAD (empty branch → clear)
6 - ``test_stash_list_shows_entries`` — list returns entries newest-first
7 - ``test_stash_apply_keeps_entry`` — apply does not remove the entry
8 - ``test_stash_drop_removes_entry`` — drop removes exactly the target entry
9 - ``test_stash_clear_removes_all`` — clear empties the entire stack
10 - ``test_stash_track_scoping`` — --track scopes files saved and restored
11 - ``test_stash_section_scoping`` — --section scopes files saved and restored
12 - ``test_stash_pop_index_oob`` — pop on empty stack exits with USER_ERROR
13 - ``test_stash_apply_index_oob`` — apply on missing index raises IndexError
14 - ``test_stash_drop_index_oob`` — drop on missing index raises IndexError
15 - ``test_stash_multiple_entries`` — multiple pushes produce a stack
16 - ``test_stash_push_empty_workdir`` — push on empty workdir is a noop
17 - ``test_stash_missing_objects`` — apply when object store empty reports missing
18 - ``test_stash_push_with_head_manifest`` — push restores HEAD snapshot to workdir
19 - ``test_stash_message_stored`` — custom --message is preserved
20 """
21 from __future__ import annotations
22
23 import json
24 import pathlib
25 import uuid
26
27 import pytest
28
29 from maestro.services.muse_stash import (
30 StashApplyResult,
31 StashEntry,
32 StashPushResult,
33 apply_stash,
34 clear_stash,
35 drop_stash,
36 list_stash,
37 push_stash,
38 )
39
40
41 # ---------------------------------------------------------------------------
42 # Helpers
43 # ---------------------------------------------------------------------------
44
45
46 def _init_repo(root: pathlib.Path, repo_id: str | None = None) -> str:
47 """Create a minimal .muse/ layout (no DB required for stash tests)."""
48 rid = repo_id or str(uuid.uuid4())
49 muse = root / ".muse"
50 (muse / "refs" / "heads").mkdir(parents=True)
51 (muse / "repo.json").write_text(json.dumps({"repo_id": rid, "schema_version": "1"}))
52 (muse / "HEAD").write_text("refs/heads/main")
53 (muse / "refs" / "heads" / "main").write_text("")
54 return rid
55
56
57 def _populate_workdir(
58 root: pathlib.Path,
59 files: dict[str, bytes] | None = None,
60 ) -> None:
61 """Write files into muse-work/."""
62 workdir = root / "muse-work"
63 workdir.mkdir(exist_ok=True)
64 if files is None:
65 files = {"beat.mid": b"MIDI-DATA", "lead.mp3": b"MP3-DATA"}
66 for rel, content in files.items():
67 dest = workdir / rel
68 dest.parent.mkdir(parents=True, exist_ok=True)
69 dest.write_bytes(content)
70
71
72 def _object_path(root: pathlib.Path, oid: str) -> pathlib.Path:
73 """Return sharded object store path."""
74 return root / ".muse" / "objects" / oid[:2] / oid[2:]
75
76
77 # ---------------------------------------------------------------------------
78 # Regression — push / pop round-trip
79 # ---------------------------------------------------------------------------
80
81
82 def test_stash_push_pop_roundtrip(tmp_path: pathlib.Path) -> None:
83 """Regression: push saves file content; pop restores it exactly."""
84 _init_repo(tmp_path)
85 _populate_workdir(tmp_path, {"beat.mid": b"wip-chorus-beat"})
86
87 # Push — no HEAD commit, so workdir is cleared after push
88 result = push_stash(tmp_path, message="WIP chorus", head_manifest=None)
89
90 assert result.files_stashed == 1
91 assert result.stash_ref == "stash@{0}"
92 assert result.message == "WIP chorus"
93 # After full push (no HEAD), workdir should be cleared
94 workdir = tmp_path / "muse-work"
95 remaining = list(workdir.rglob("*"))
96 assert not any(f.is_file() for f in remaining)
97
98 # Pop — restores the stashed file
99 pop_result = apply_stash(tmp_path, 0, drop=True)
100
101 assert pop_result.dropped is True
102 assert pop_result.files_applied == 1
103 assert (tmp_path / "muse-work" / "beat.mid").read_bytes() == b"wip-chorus-beat"
104 # Stash stack should now be empty
105 assert list_stash(tmp_path) == []
106
107
108 # ---------------------------------------------------------------------------
109 # Push clears workdir (no HEAD commit)
110 # ---------------------------------------------------------------------------
111
112
113 def test_stash_push_clears_workdir(tmp_path: pathlib.Path) -> None:
114 """Full push with no HEAD commit clears muse-work/ after stashing."""
115 _init_repo(tmp_path)
116 _populate_workdir(tmp_path, {"a.mid": b"A", "b.mid": b"B"})
117
118 result = push_stash(tmp_path, head_manifest=None)
119
120 assert result.files_stashed == 2
121 assert result.head_restored is False
122
123 workdir = tmp_path / "muse-work"
124 files = [f for f in workdir.rglob("*") if f.is_file()]
125 assert files == [], "workdir should be empty after full push with no HEAD"
126
127
128 # ---------------------------------------------------------------------------
129 # Push with HEAD manifest restores HEAD snapshot
130 # ---------------------------------------------------------------------------
131
132
133 def test_stash_push_with_head_manifest(tmp_path: pathlib.Path) -> None:
134 """Push with a HEAD manifest writes HEAD files back to muse-work/."""
135 from maestro.muse_cli.snapshot import hash_file
136
137 _init_repo(tmp_path)
138
139 # Simulate HEAD commit: store a "committed" file in the object store
140 head_file_content = b"committed-beat"
141 # Compute oid by first writing a temp file
142 tmp_file = tmp_path / "tmp_head_file"
143 tmp_file.write_bytes(head_file_content)
144 oid = hash_file(tmp_file)
145 tmp_file.unlink()
146
147 obj_dest = _object_path(tmp_path, oid)
148 obj_dest.parent.mkdir(parents=True, exist_ok=True)
149 obj_dest.write_bytes(head_file_content)
150
151 head_manifest = {"beat.mid": oid}
152
153 # Populate workdir with WIP changes
154 _populate_workdir(tmp_path, {"beat.mid": b"wip-chorus", "synth.mid": b"wip-synth"})
155
156 result = push_stash(tmp_path, message="WIP", head_manifest=head_manifest)
157
158 assert result.files_stashed == 2
159 assert result.head_restored is True
160 # beat.mid should now be the HEAD version
161 assert (tmp_path / "muse-work" / "beat.mid").read_bytes() == head_file_content
162 # synth.mid was not in HEAD → should be deleted after full push
163 assert not (tmp_path / "muse-work" / "synth.mid").exists()
164
165
166 # ---------------------------------------------------------------------------
167 # list
168 # ---------------------------------------------------------------------------
169
170
171 def test_stash_list_shows_entries(tmp_path: pathlib.Path) -> None:
172 """list_stash returns entries newest-first with correct indices."""
173 _init_repo(tmp_path)
174
175 _populate_workdir(tmp_path, {"a.mid": b"A"})
176 push_stash(tmp_path, message="first stash", head_manifest=None)
177
178 _populate_workdir(tmp_path, {"b.mid": b"B"})
179 push_stash(tmp_path, message="second stash", head_manifest=None)
180
181 entries = list_stash(tmp_path)
182 assert len(entries) == 2
183 # Newest first
184 assert entries[0].index == 0
185 assert entries[1].index == 1
186 # Messages are preserved
187 messages = {e.message for e in entries}
188 assert "first stash" in messages
189 assert "second stash" in messages
190
191
192 # ---------------------------------------------------------------------------
193 # apply keeps entry
194 # ---------------------------------------------------------------------------
195
196
197 def test_stash_apply_keeps_entry(tmp_path: pathlib.Path) -> None:
198 """apply does NOT remove the stash entry (drop=False)."""
199 _init_repo(tmp_path)
200 _populate_workdir(tmp_path, {"x.mid": b"WIP"})
201
202 push_stash(tmp_path, head_manifest=None)
203
204 result = apply_stash(tmp_path, 0, drop=False)
205
206 assert result.dropped is False
207 # Entry still on the stack
208 assert len(list_stash(tmp_path)) == 1
209 # File restored
210 assert (tmp_path / "muse-work" / "x.mid").read_bytes() == b"WIP"
211
212
213 # ---------------------------------------------------------------------------
214 # drop
215 # ---------------------------------------------------------------------------
216
217
218 def test_stash_drop_removes_entry(tmp_path: pathlib.Path) -> None:
219 """drop_stash removes exactly the targeted entry."""
220 _init_repo(tmp_path)
221
222 _populate_workdir(tmp_path, {"a.mid": b"A"})
223 push_stash(tmp_path, message="entry-A", head_manifest=None)
224
225 _populate_workdir(tmp_path, {"b.mid": b"B"})
226 push_stash(tmp_path, message="entry-B", head_manifest=None)
227
228 # Stack: index 0 = entry-B (newest), index 1 = entry-A
229 dropped = drop_stash(tmp_path, index=1)
230
231 assert dropped.message == "entry-A"
232 remaining = list_stash(tmp_path)
233 assert len(remaining) == 1
234 assert remaining[0].message == "entry-B"
235
236
237 # ---------------------------------------------------------------------------
238 # clear
239 # ---------------------------------------------------------------------------
240
241
242 def test_stash_clear_removes_all(tmp_path: pathlib.Path) -> None:
243 """clear_stash removes all entries and returns the count."""
244 _init_repo(tmp_path)
245
246 for label in ["first", "second", "third"]:
247 _populate_workdir(tmp_path, {f"{label}.mid": label.encode()})
248 push_stash(tmp_path, message=label, head_manifest=None)
249
250 count = clear_stash(tmp_path)
251
252 assert count == 3
253 assert list_stash(tmp_path) == []
254
255
256 # ---------------------------------------------------------------------------
257 # Track scoping
258 # ---------------------------------------------------------------------------
259
260
261 def test_stash_track_scoping(tmp_path: pathlib.Path) -> None:
262 """--track scopes stash to tracks/<track>/ files only."""
263 _init_repo(tmp_path)
264 _populate_workdir(
265 tmp_path,
266 {
267 "tracks/drums/beat.mid": b"drums-wip",
268 "tracks/bass/bass.mid": b"bass-wip",
269 },
270 )
271
272 result = push_stash(tmp_path, track="drums", head_manifest=None)
273
274 # Only drums file stashed
275 assert result.files_stashed == 1
276 entries = list_stash(tmp_path)
277 assert len(entries) == 1
278 assert "tracks/drums/beat.mid" in entries[0].manifest
279 assert "tracks/bass/bass.mid" not in entries[0].manifest
280 assert entries[0].track == "drums"
281 # bass file should still be in workdir (unscoped push leaves it)
282 assert (tmp_path / "muse-work" / "tracks" / "bass" / "bass.mid").exists()
283
284
285 # ---------------------------------------------------------------------------
286 # Section scoping
287 # ---------------------------------------------------------------------------
288
289
290 def test_stash_section_scoping(tmp_path: pathlib.Path) -> None:
291 """--section scopes stash to sections/<section>/ files only."""
292 _init_repo(tmp_path)
293 _populate_workdir(
294 tmp_path,
295 {
296 "sections/chorus/chords.mid": b"chorus-wip",
297 "sections/verse/lead.mid": b"verse-stable",
298 },
299 )
300
301 result = push_stash(tmp_path, section="chorus", head_manifest=None)
302
303 assert result.files_stashed == 1
304 entries = list_stash(tmp_path)
305 assert "sections/chorus/chords.mid" in entries[0].manifest
306 assert "sections/verse/lead.mid" not in entries[0].manifest
307 assert entries[0].section == "chorus"
308 # verse file untouched
309 assert (tmp_path / "muse-work" / "sections" / "verse" / "lead.mid").exists()
310
311
312 # ---------------------------------------------------------------------------
313 # OOB index handling
314 # ---------------------------------------------------------------------------
315
316
317 def test_stash_pop_on_empty_stack_raises(tmp_path: pathlib.Path) -> None:
318 """apply_stash raises IndexError when stash is empty."""
319 _init_repo(tmp_path)
320
321 with pytest.raises(IndexError):
322 apply_stash(tmp_path, 0, drop=True)
323
324
325 def test_stash_apply_index_oob_raises(tmp_path: pathlib.Path) -> None:
326 """apply_stash raises IndexError for an out-of-range index."""
327 _init_repo(tmp_path)
328 _populate_workdir(tmp_path, {"x.mid": b"X"})
329 push_stash(tmp_path, head_manifest=None)
330
331 with pytest.raises(IndexError):
332 apply_stash(tmp_path, 5, drop=False)
333
334
335 def test_stash_drop_index_oob_raises(tmp_path: pathlib.Path) -> None:
336 """drop_stash raises IndexError for an out-of-range index."""
337 _init_repo(tmp_path)
338
339 with pytest.raises(IndexError):
340 drop_stash(tmp_path, 0)
341
342
343 # ---------------------------------------------------------------------------
344 # Multiple entries stack ordering
345 # ---------------------------------------------------------------------------
346
347
348 def test_stash_multiple_entries(tmp_path: pathlib.Path) -> None:
349 """Multiple pushes build a stack; index 0 is always most recent."""
350 _init_repo(tmp_path)
351
352 content_map = {"first": b"v1", "second": b"v2", "third": b"v3"}
353 for label, content in content_map.items():
354 _populate_workdir(tmp_path, {f"{label}.mid": content})
355 push_stash(tmp_path, message=label, head_manifest=None)
356
357 entries = list_stash(tmp_path)
358 assert len(entries) == 3
359 # index 0 = most recently pushed = "third"
360 assert entries[0].message == "third"
361 assert entries[1].message == "second"
362 assert entries[2].message == "first"
363
364
365 # ---------------------------------------------------------------------------
366 # Empty workdir push is a noop
367 # ---------------------------------------------------------------------------
368
369
370 def test_stash_push_empty_workdir(tmp_path: pathlib.Path) -> None:
371 """push_stash on an empty workdir returns files_stashed=0 (noop)."""
372 _init_repo(tmp_path)
373 (tmp_path / "muse-work").mkdir()
374
375 result = push_stash(tmp_path, head_manifest=None)
376
377 assert result.files_stashed == 0
378 assert result.stash_ref == ""
379 assert list_stash(tmp_path) == []
380
381
382 # ---------------------------------------------------------------------------
383 # Missing objects are reported (not silently dropped)
384 # ---------------------------------------------------------------------------
385
386
387 def test_stash_missing_objects_reported(tmp_path: pathlib.Path) -> None:
388 """apply_stash reports paths whose objects are absent from the store."""
389 from maestro.muse_cli.snapshot import hash_file
390
391 _init_repo(tmp_path)
392 _populate_workdir(tmp_path, {"chorus.mid": b"wip"})
393
394 # Push normally (object is stored)
395 push_stash(tmp_path, head_manifest=None)
396
397 # Manually delete the object to simulate missing store entry
398 entries = list_stash(tmp_path)
399 oid = entries[0].manifest["chorus.mid"]
400 obj_file = _object_path(tmp_path, oid)
401 assert obj_file.exists()
402 obj_file.unlink()
403
404 # Apply should report the missing file, not crash
405 result = apply_stash(tmp_path, 0, drop=False)
406
407 assert "chorus.mid" in result.missing
408 assert result.files_applied == 0
409
410
411 # ---------------------------------------------------------------------------
412 # Custom message is stored
413 # ---------------------------------------------------------------------------
414
415
416 def test_stash_message_stored(tmp_path: pathlib.Path) -> None:
417 """Custom --message text is persisted in the stash entry."""
418 _init_repo(tmp_path)
419 _populate_workdir(tmp_path, {"synth.mid": b"wip"})
420
421 push_stash(tmp_path, message="half-finished synth arpeggio", head_manifest=None)
422
423 entries = list_stash(tmp_path)
424 assert entries[0].message == "half-finished synth arpeggio"