gabriel / muse public
test_cmd_stash_hardening.py python
2,167 lines 104.1 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Comprehensive hardening tests for ``muse stash``.
2
3 Covers all changes introduced in the stash command review:
4
5 Unit
6 ----
7 - Parser flags: -m/--message, index arg for pop/drop/show/apply
8 - Dead-code removal: _read_branch absent
9 - assert_not_symlink in _load_stash
10 - fsync in _save_stash
11 - _resolve_index validates bounds and non-integer input
12 - _verify_manifest_objects catches missing objects
13
14 Integration
15 -----------
16 - Error messages routed to stderr, stdout clean
17 - JSON schema stable and complete across all subcommands
18 - push: status/files_count/message fields present
19 - pop: status/stash_size_after/message fields present
20 - drop: snapshot_id/branch/index fields present
21 - list: files_count/message fields present per entry
22 - show: files list present
23 - apply: stash entry preserved after apply
24 - nothing_to_stash: complete JSON schema with null fields
25 - -m/--message annotation stored and retrieved
26 - pop/drop by index works correctly
27 - show by index works correctly
28 - Object-store integrity check blocks pop with missing objects
29
30 End-to-end
31 ----------
32 - Round-trip: push → pop restores original state
33 - Round-trip: push → apply → apply (idempotent) preserves stash
34 - Round-trip: push → drop removes entry
35 - Stash stack ordering (LIFO)
36 - Stash persists across multiple commands
37
38 Security
39 --------
40 - ANSI in branch name sanitized in text output
41 - ANSI in message sanitized in text output
42 - ANSI in file paths sanitized in show output
43 - Symlink at stash.json returns empty stack (not traversed)
44 - --format invalid value exits 1 to stderr
45
46 Stress
47 ------
48 - 100 sequential push/pop cycles
49 - Stash stack with 50 entries then drop all
50 - Concurrent stash operations to isolated repos
51 """
52
53 from __future__ import annotations
54
55 import argparse
56 import inspect
57 import json
58 import os
59 import pathlib
60 import tempfile
61 import threading
62 import time
63 from typing import TYPE_CHECKING
64
65 import pytest
66
67 if TYPE_CHECKING:
68 from muse.cli.commands.stash import StashEntry
69
70 from tests.cli_test_helper import CliRunner
71
72 cli = None # argparse migration — CliRunner ignores this arg
73 runner = CliRunner()
74
75
76 # ---------------------------------------------------------------------------
77 # Shared helpers
78 # ---------------------------------------------------------------------------
79
80 def _env(root: pathlib.Path) -> Manifest:
81 return {"MUSE_REPO_ROOT": str(root)}
82
83
84 @pytest.fixture()
85 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
86 """Fresh repo with one committed file (a.py) and one dirty file (b.py)."""
87 monkeypatch.chdir(tmp_path)
88 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
89 r = runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
90 assert r.exit_code == 0, r.output
91 (tmp_path / "a.py").write_text("x = 1\n")
92 r = runner.invoke(cli, ["commit", "-m", "base"], env=_env(tmp_path), catch_exceptions=False)
93 assert r.exit_code == 0, r.output
94 (tmp_path / "b.py").write_text("y = 2\n")
95 return tmp_path
96
97
98 @pytest.fixture()
99 def stashed_repo(repo: pathlib.Path) -> pathlib.Path:
100 """repo fixture with one stash entry already pushed."""
101 r = runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
102 assert r.exit_code == 0, r.output
103 return repo
104
105
106 # ---------------------------------------------------------------------------
107 # Unit — parser flags and dead-code removal
108 # ---------------------------------------------------------------------------
109
110 class TestRegisterFlags:
111 def _parse(self, *args: str) -> argparse.Namespace:
112 import muse.cli.commands.stash as m
113 p = argparse.ArgumentParser()
114 sub = p.add_subparsers()
115 m.register(sub)
116 return p.parse_args(["stash", *args])
117
118 def test_message_short(self) -> None:
119 ns = self._parse("-m", "wip")
120 assert ns.message == "wip"
121
122 def test_message_long(self) -> None:
123 ns = self._parse("--message", "my work")
124 assert ns.message == "my work"
125
126 def test_message_default_none(self) -> None:
127 ns = self._parse()
128 assert ns.message is None
129
130 def test_format_json_shorthand(self) -> None:
131 ns = self._parse("--json")
132 assert ns.fmt == "json"
133
134 def test_pop_index_arg(self) -> None:
135 ns = self._parse("pop", "2")
136 assert ns.index == "2"
137
138 def test_pop_index_default_none(self) -> None:
139 ns = self._parse("pop")
140 assert ns.index is None
141
142 def test_drop_index_arg(self) -> None:
143 ns = self._parse("drop", "1")
144 assert ns.index == "1"
145
146 def test_show_index_arg(self) -> None:
147 ns = self._parse("show", "0")
148 assert ns.index == "0"
149
150 def test_apply_index_arg(self) -> None:
151 ns = self._parse("apply", "0")
152 assert ns.index == "0"
153
154
155 class TestDeadCodeRemoval:
156 def test_no_read_branch_wrapper(self) -> None:
157 import muse.cli.commands.stash as m
158 assert not hasattr(m, "_read_branch"), "_read_branch must be deleted"
159
160 def test_assert_not_symlink_in_load_stash(self) -> None:
161 import muse.cli.commands.stash as m
162 assert "assert_not_symlink" in inspect.getsource(m._load_stash)
163
164 def test_fsync_in_save_stash(self) -> None:
165 import muse.cli.commands.stash as m
166 assert "fsync" in inspect.getsource(m._save_stash)
167
168 def test_verify_manifest_objects_exists(self) -> None:
169 import muse.cli.commands.stash as m
170 assert hasattr(m, "_verify_manifest_objects")
171
172
173 class TestResolveIndex:
174 def _entries(self, n: int) -> list["StashEntry"]:
175 from muse.cli.commands.stash import StashEntry
176 return [
177 StashEntry(
178 snapshot_id="a" * 64, manifest={}, branch="main",
179 stashed_at="2026-01-01T00:00:00+00:00", message=None,
180 )
181 for _ in range(n)
182 ]
183
184 def test_default_returns_0(self) -> None:
185 from muse.cli.commands.stash import _resolve_index
186 assert _resolve_index(self._entries(3), None) == 0
187
188 def test_explicit_index(self) -> None:
189 from muse.cli.commands.stash import _resolve_index
190 assert _resolve_index(self._entries(3), "2") == 2
191
192 def test_out_of_range_raises(self) -> None:
193 from muse.cli.commands.stash import _resolve_index
194 with pytest.raises(ValueError, match="out of range"):
195 _resolve_index(self._entries(2), "5")
196
197 def test_negative_raises(self) -> None:
198 from muse.cli.commands.stash import _resolve_index
199 with pytest.raises(ValueError):
200 _resolve_index(self._entries(2), "-1")
201
202 def test_non_integer_raises(self) -> None:
203 from muse.cli.commands.stash import _resolve_index
204 with pytest.raises(ValueError, match="integer"):
205 _resolve_index(self._entries(2), "abc")
206
207
208 # ---------------------------------------------------------------------------
209 # Integration — JSON schema completeness
210 # ---------------------------------------------------------------------------
211
212 class TestPushJsonSchema:
213 _REQUIRED = {"status", "snapshot_id", "branch", "stashed_at", "message", "files_count", "stash_size"}
214
215 def test_stash_schema_complete(self, repo: pathlib.Path) -> None:
216 r = runner.invoke(cli, ["stash", "--json"], env=_env(repo), catch_exceptions=False)
217 assert r.exit_code == 0, r.output
218 d = json.loads(r.output)
219 assert self._REQUIRED <= d.keys()
220
221 def test_status_is_stashed(self, repo: pathlib.Path) -> None:
222 r = runner.invoke(cli, ["stash", "--json"], env=_env(repo), catch_exceptions=False)
223 d = json.loads(r.output)
224 assert d["status"] == "stashed"
225
226 def test_files_count_positive(self, repo: pathlib.Path) -> None:
227 r = runner.invoke(cli, ["stash", "--json"], env=_env(repo), catch_exceptions=False)
228 d = json.loads(r.output)
229 assert d["files_count"] > 0
230
231 def test_message_default_null(self, repo: pathlib.Path) -> None:
232 r = runner.invoke(cli, ["stash", "--json"], env=_env(repo), catch_exceptions=False)
233 d = json.loads(r.output)
234 assert d["message"] is None
235
236 def test_message_with_flag(self, repo: pathlib.Path) -> None:
237 r = runner.invoke(cli, ["stash", "-m", "my note", "--json"], env=_env(repo), catch_exceptions=False)
238 d = json.loads(r.output)
239 assert d["message"] == "my note"
240
241 def test_nothing_to_stash_schema_complete(self, repo: pathlib.Path) -> None:
242 """nothing_to_stash must emit same keys with null snapshot_id."""
243 # Pop any prior stash so workdir is clean
244 runner.invoke(cli, ["stash", "drop"], env=_env(repo))
245 # Make workdir match HEAD exactly
246 (repo / "b.py").unlink(missing_ok=True)
247 r = runner.invoke(cli, ["stash", "--json"], env=_env(repo), catch_exceptions=False)
248 d = json.loads(r.output)
249 assert self._REQUIRED <= d.keys()
250 assert d["status"] == "nothing_to_stash"
251 assert d["snapshot_id"] is None
252
253
254 class TestPopJsonSchema:
255 _REQUIRED = {"status", "snapshot_id", "branch", "stashed_at", "message", "files_count", "stash_size_after"}
256
257 def test_pop_schema_complete(self, stashed_repo: pathlib.Path) -> None:
258 r = runner.invoke(cli, ["stash", "pop", "--json"], env=_env(stashed_repo), catch_exceptions=False)
259 assert r.exit_code == 0, r.output
260 d = json.loads(r.output)
261 assert self._REQUIRED <= d.keys()
262
263 def test_pop_status_is_popped(self, stashed_repo: pathlib.Path) -> None:
264 r = runner.invoke(cli, ["stash", "pop", "--json"], env=_env(stashed_repo), catch_exceptions=False)
265 d = json.loads(r.output)
266 assert d["status"] == "popped"
267
268 def test_pop_stash_size_after_decremented(self, stashed_repo: pathlib.Path) -> None:
269 r = runner.invoke(cli, ["stash", "pop", "--json"], env=_env(stashed_repo), catch_exceptions=False)
270 d = json.loads(r.output)
271 assert d["stash_size_after"] == 0
272
273 def test_pop_empty_to_stderr(self, repo: pathlib.Path) -> None:
274 r = runner.invoke(cli, ["stash", "pop"], env=_env(repo))
275 assert r.exit_code != 0
276 assert "no stash" in (r.stderr or "").lower()
277
278
279 class TestDropJsonSchema:
280 _REQUIRED = {"status", "index", "snapshot_id", "branch", "stash_size"}
281
282 def test_drop_schema_complete(self, stashed_repo: pathlib.Path) -> None:
283 r = runner.invoke(cli, ["stash", "drop", "--json"], env=_env(stashed_repo), catch_exceptions=False)
284 assert r.exit_code == 0, r.output
285 d = json.loads(r.output)
286 assert self._REQUIRED <= d.keys()
287
288 def test_drop_status_is_dropped(self, stashed_repo: pathlib.Path) -> None:
289 r = runner.invoke(cli, ["stash", "drop", "--json"], env=_env(stashed_repo), catch_exceptions=False)
290 d = json.loads(r.output)
291 assert d["status"] == "dropped"
292
293 def test_drop_snapshot_id_is_sha256(self, stashed_repo: pathlib.Path) -> None:
294 r = runner.invoke(cli, ["stash", "drop", "--json"], env=_env(stashed_repo), catch_exceptions=False)
295 d = json.loads(r.output)
296 assert isinstance(d["snapshot_id"], str) and len(d["snapshot_id"]) == 64
297
298 def test_drop_empty_to_stderr(self, repo: pathlib.Path) -> None:
299 r = runner.invoke(cli, ["stash", "drop"], env=_env(repo))
300 assert r.exit_code != 0
301 assert "no stash" in (r.stderr or "").lower()
302
303
304 class TestListJsonSchema:
305 _ENTRY_REQUIRED = {"index", "snapshot_id", "branch", "stashed_at", "message", "files_count"}
306
307 def test_list_schema_complete(self, stashed_repo: pathlib.Path) -> None:
308 r = runner.invoke(cli, ["stash", "list", "--json"], env=_env(stashed_repo), catch_exceptions=False)
309 assert r.exit_code == 0, r.output
310 entries = json.loads(r.output)
311 assert len(entries) == 1
312 assert self._ENTRY_REQUIRED <= entries[0].keys()
313
314 def test_list_files_count_positive(self, stashed_repo: pathlib.Path) -> None:
315 r = runner.invoke(cli, ["stash", "list", "--json"], env=_env(stashed_repo), catch_exceptions=False)
316 entries = json.loads(r.output)
317 assert entries[0]["files_count"] > 0
318
319 def test_list_message_field_present(self, stashed_repo: pathlib.Path) -> None:
320 r = runner.invoke(cli, ["stash", "list", "--json"], env=_env(stashed_repo), catch_exceptions=False)
321 entries = json.loads(r.output)
322 assert "message" in entries[0]
323
324 def test_list_empty_returns_empty_array(self, repo: pathlib.Path) -> None:
325 r = runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False)
326 assert r.exit_code == 0
327 assert json.loads(r.output) == []
328
329 def test_list_with_message(self, repo: pathlib.Path) -> None:
330 runner.invoke(cli, ["stash", "-m", "annotated"], env=_env(repo), catch_exceptions=False)
331 r = runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False)
332 entries = json.loads(r.output)
333 assert entries[0]["message"] == "annotated"
334
335
336 class TestShowJsonSchema:
337 _REQUIRED = {"index", "snapshot_id", "branch", "stashed_at", "message", "files"}
338
339 def test_show_schema_complete(self, stashed_repo: pathlib.Path) -> None:
340 r = runner.invoke(cli, ["stash", "show", "--json"], env=_env(stashed_repo), catch_exceptions=False)
341 assert r.exit_code == 0, r.output
342 d = json.loads(r.output)
343 assert self._REQUIRED <= d.keys()
344
345 def test_show_files_is_list_of_strings(self, stashed_repo: pathlib.Path) -> None:
346 r = runner.invoke(cli, ["stash", "show", "--json"], env=_env(stashed_repo), catch_exceptions=False)
347 d = json.loads(r.output)
348 assert isinstance(d["files"], list)
349 assert all(isinstance(f, str) for f in d["files"])
350
351 def test_show_empty_to_stderr(self, repo: pathlib.Path) -> None:
352 r = runner.invoke(cli, ["stash", "show"], env=_env(repo))
353 assert r.exit_code != 0
354 assert "no stash" in (r.stderr or "").lower()
355
356
357 class TestApply:
358 def test_apply_preserves_stash_entry(self, stashed_repo: pathlib.Path) -> None:
359 before = json.loads(
360 runner.invoke(cli, ["stash", "list", "--json"], env=_env(stashed_repo), catch_exceptions=False).output
361 )
362 runner.invoke(cli, ["stash", "apply", "--json"], env=_env(stashed_repo), catch_exceptions=False)
363 after = json.loads(
364 runner.invoke(cli, ["stash", "list", "--json"], env=_env(stashed_repo), catch_exceptions=False).output
365 )
366 assert len(before) == len(after), "apply must not remove the stash entry"
367
368 def test_apply_restores_workdir(self, stashed_repo: pathlib.Path) -> None:
369 b_py = stashed_repo / "b.py"
370 # After stash push, b.py was removed from workdir
371 assert not b_py.exists()
372 runner.invoke(cli, ["stash", "apply"], env=_env(stashed_repo), catch_exceptions=False)
373 assert b_py.exists(), "apply must restore stashed files to workdir"
374
375 def test_apply_json_status(self, stashed_repo: pathlib.Path) -> None:
376 r = runner.invoke(cli, ["stash", "apply", "--json"], env=_env(stashed_repo), catch_exceptions=False)
377 d = json.loads(r.output)
378 assert d["status"] == "applied"
379 assert d["stash_size"] == 1
380
381 def test_apply_empty_to_stderr(self, repo: pathlib.Path) -> None:
382 r = runner.invoke(cli, ["stash", "apply"], env=_env(repo))
383 assert r.exit_code != 0
384 assert "no stash" in (r.stderr or "").lower()
385
386
387 class TestIndexArg:
388 def _push_n(self, repo: pathlib.Path, n: int) -> list[str] :
389 """Push n distinct stash entries, return their snapshot_ids."""
390 ids: list[str] = []
391 for i in range(n):
392 (repo / f"w{i}.py").write_text(f"data {i}\n")
393 r = runner.invoke(cli, ["stash", "--json"], env=_env(repo), catch_exceptions=False)
394 ids.append(json.loads(r.output)["snapshot_id"])
395 return ids
396
397 def test_pop_by_index(self, repo: pathlib.Path) -> None:
398 ids = self._push_n(repo, 3)
399 # Stash is LIFO: index 0 = newest (ids[2]), index 2 = oldest (ids[0])
400 r = runner.invoke(cli, ["stash", "pop", "2", "--json"], env=_env(repo), catch_exceptions=False)
401 assert r.exit_code == 0, r.output
402 d = json.loads(r.output)
403 assert d["snapshot_id"] == ids[0] # oldest = index 2
404
405 def test_drop_by_index(self, repo: pathlib.Path) -> None:
406 ids = self._push_n(repo, 3)
407 r = runner.invoke(cli, ["stash", "drop", "1", "--json"], env=_env(repo), catch_exceptions=False)
408 assert r.exit_code == 0, r.output
409 d = json.loads(r.output)
410 assert d["index"] == 1
411
412 def test_show_by_index(self, repo: pathlib.Path) -> None:
413 self._push_n(repo, 3)
414 r = runner.invoke(cli, ["stash", "show", "1", "--json"], env=_env(repo), catch_exceptions=False)
415 assert r.exit_code == 0, r.output
416 d = json.loads(r.output)
417 assert d["index"] == 1
418
419 def test_out_of_range_to_stderr(self, repo: pathlib.Path) -> None:
420 self._push_n(repo, 2)
421 r = runner.invoke(cli, ["stash", "pop", "99"], env=_env(repo))
422 assert r.exit_code != 0
423 assert "out of range" in (r.stderr or "").lower()
424
425 def test_non_integer_to_stderr(self, repo: pathlib.Path) -> None:
426 self._push_n(repo, 1)
427 r = runner.invoke(cli, ["stash", "pop", "abc"], env=_env(repo))
428 assert r.exit_code != 0
429 assert "integer" in (r.stderr or "").lower()
430
431
432 class TestObjectIntegrity:
433 def test_pop_with_missing_object_exits_internal_error(
434 self, stashed_repo: pathlib.Path
435 ) -> None:
436 """pop must refuse to restore if object store objects are missing."""
437 stash_data = json.loads((stashed_repo / ".muse/stash.json").read_text())
438 manifest = stash_data[0]["delta"]
439 # Delete one object from the object store to simulate corruption
440 for obj_id in list(manifest.values())[:1]:
441 obj_path = stashed_repo / ".muse" / "objects" / obj_id[:2] / obj_id[2:]
442 if obj_path.exists():
443 obj_path.unlink()
444 break
445
446 r = runner.invoke(cli, ["stash", "pop"], env=_env(stashed_repo))
447 assert r.exit_code == 3, (
448 f"Expected INTERNAL_ERROR (3), got {r.exit_code}: {r.output.strip()}"
449 )
450
451 def test_apply_with_missing_object_exits_internal_error(
452 self, stashed_repo: pathlib.Path
453 ) -> None:
454 stash_data = json.loads((stashed_repo / ".muse/stash.json").read_text())
455 manifest = stash_data[0]["delta"]
456 for obj_id in list(manifest.values())[:1]:
457 obj_path = stashed_repo / ".muse" / "objects" / obj_id[:2] / obj_id[2:]
458 if obj_path.exists():
459 obj_path.unlink()
460 break
461
462 r = runner.invoke(cli, ["stash", "apply"], env=_env(stashed_repo))
463 assert r.exit_code == 3
464
465
466 # ---------------------------------------------------------------------------
467 # End-to-end — round-trips
468 # ---------------------------------------------------------------------------
469
470 class TestRoundTrips:
471 def test_push_pop_restores_state(self, repo: pathlib.Path) -> None:
472 b_content = (repo / "b.py").read_text()
473 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
474 assert not (repo / "b.py").exists()
475 runner.invoke(cli, ["stash", "pop"], env=_env(repo), catch_exceptions=False)
476 assert (repo / "b.py").exists()
477 assert (repo / "b.py").read_text() == b_content
478
479 def test_push_apply_apply_idempotent(self, repo: pathlib.Path) -> None:
480 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
481 runner.invoke(cli, ["stash", "apply"], env=_env(repo), catch_exceptions=False)
482 r1 = json.loads(runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False).output)
483 runner.invoke(cli, ["stash", "apply"], env=_env(repo), catch_exceptions=False)
484 r2 = json.loads(runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False).output)
485 assert len(r1) == len(r2), "apply is idempotent with respect to stash size"
486
487 def test_push_drop_removes_entry(self, repo: pathlib.Path) -> None:
488 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
489 runner.invoke(cli, ["stash", "drop"], env=_env(repo), catch_exceptions=False)
490 entries = json.loads(runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False).output)
491 assert entries == []
492
493 def test_stash_stack_is_lifo(self, repo: pathlib.Path) -> None:
494 """The most-recently-pushed stash must be at index 0."""
495 (repo / "b.py").write_text("first\n")
496 runner.invoke(cli, ["stash", "-m", "first"], env=_env(repo), catch_exceptions=False)
497 (repo / "b.py").write_text("second\n")
498 runner.invoke(cli, ["stash", "-m", "second"], env=_env(repo), catch_exceptions=False)
499 entries = json.loads(runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False).output)
500 assert entries[0]["message"] == "second"
501 assert entries[1]["message"] == "first"
502
503 def test_message_survives_round_trip(self, repo: pathlib.Path) -> None:
504 runner.invoke(cli, ["stash", "-m", "wip: auth changes"], env=_env(repo), catch_exceptions=False)
505 r = runner.invoke(cli, ["stash", "pop", "--json"], env=_env(repo), catch_exceptions=False)
506 d = json.loads(r.output)
507 assert d["message"] == "wip: auth changes"
508
509 def test_stash_persists_across_invocations(self, repo: pathlib.Path) -> None:
510 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
511 # Simulate a new CLI invocation by calling list separately
512 entries = json.loads(runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False).output)
513 assert len(entries) == 1
514
515 def test_nothing_to_stash_does_not_push(self, repo: pathlib.Path) -> None:
516 (repo / "b.py").unlink(missing_ok=True)
517 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
518 entries = json.loads(runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False).output)
519 assert len(entries) == 0
520
521
522 # ---------------------------------------------------------------------------
523 # Security
524 # ---------------------------------------------------------------------------
525
526 class TestSecurity:
527 def test_ansi_in_branch_sanitized(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
528 """Branch name with ANSI codes must not appear raw in stash list text."""
529 monkeypatch.chdir(tmp_path)
530 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
531 runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
532 (tmp_path / "x.py").write_text("data\n")
533 runner.invoke(cli, ["commit", "-m", "base"], env=_env(tmp_path), catch_exceptions=False)
534 (tmp_path / "y.py").write_text("dirty\n")
535 runner.invoke(cli, ["stash"], env=_env(tmp_path), catch_exceptions=False)
536
537 # Manually inject ANSI into the stash.json branch field
538 stash_path = tmp_path / ".muse/stash.json"
539 raw = json.loads(stash_path.read_text())
540 raw[0]["branch"] = "\x1b[31mmain\x1b[0m"
541 stash_path.write_text(json.dumps(raw))
542
543 r = runner.invoke(cli, ["stash", "list"], env=_env(tmp_path), catch_exceptions=False)
544 assert "\x1b[31m" not in r.output
545
546 def test_ansi_in_message_sanitized(self, repo: pathlib.Path) -> None:
547 """Message with ANSI codes must not appear raw in text output."""
548 runner.invoke(cli, ["stash", "-m", "\x1b[31mwip\x1b[0m"], env=_env(repo), catch_exceptions=False)
549 r = runner.invoke(cli, ["stash", "list"], env=_env(repo), catch_exceptions=False)
550 assert "\x1b[31m" not in r.output
551
552 def test_ansi_in_file_paths_sanitized_in_show(self, repo: pathlib.Path) -> None:
553 """File paths in stash show must be sanitized."""
554 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
555 stash_path = repo / ".muse/stash.json"
556 raw = json.loads(stash_path.read_text())
557 raw[0]["delta"]["\x1b[31mevil.py\x1b[0m"] = "a" * 64
558 stash_path.write_text(json.dumps(raw))
559 r = runner.invoke(cli, ["stash", "show", "0"], env=_env(repo), catch_exceptions=False)
560 assert "\x1b[31m" not in r.output
561
562 @pytest.mark.skipif(os.name == "nt", reason="symlinks require elevated privs on Windows")
563 def test_symlink_stash_returns_empty_stack(
564 self, repo: pathlib.Path
565 ) -> None:
566 """A symlink planted at .muse/stash.json must not be followed."""
567 stash_path = repo / ".muse/stash.json"
568 target = repo / "sensitive.txt"
569 target.write_text("secret")
570 stash_path.symlink_to(target)
571
572 r = runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False)
573 assert r.exit_code == 0
574 assert json.loads(r.output) == []
575
576 def test_invalid_format_to_stderr(self, repo: pathlib.Path) -> None:
577 r = runner.invoke(cli, ["stash", "--format", "xml"], env=_env(repo))
578 assert r.exit_code == 1
579 assert "xml" in (r.stderr or "").lower()
580
581 def test_pop_invalid_format_to_stderr(self, stashed_repo: pathlib.Path) -> None:
582 r = runner.invoke(cli, ["stash", "pop", "--format", "html"], env=_env(stashed_repo))
583 assert r.exit_code == 1
584 assert "html" in (r.stderr or "").lower()
585
586 def test_error_messages_in_stderr(self, repo: pathlib.Path) -> None:
587 """All 'no stash entries' error messages must go to stderr."""
588 for sub in ["pop", "apply", "drop", "show"]:
589 r = runner.invoke(cli, ["stash", sub], env=_env(repo))
590 assert r.exit_code != 0, f"stash {sub} should fail on empty stack"
591 assert "no stash" in (r.stderr or "").lower(), (
592 f"stash {sub}: error should be in stderr, got stdout={r.stdout!r} stderr={r.stderr!r}"
593 )
594
595
596 # ---------------------------------------------------------------------------
597 # Stress
598 # ---------------------------------------------------------------------------
599
600 class TestStress:
601 @pytest.mark.slow
602 def test_sequential_push_pop_cycles(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
603 """100 sequential push+pop cycles must all succeed with correct state."""
604 monkeypatch.chdir(tmp_path)
605 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
606 env = _env(tmp_path)
607 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
608 (tmp_path / "base.py").write_text("base\n")
609 runner.invoke(cli, ["commit", "-m", "base"], env=env, catch_exceptions=False)
610
611 failures: list[str] = []
612 for i in range(100):
613 (tmp_path / "work.py").write_text(f"iteration {i}\n")
614 r_push = runner.invoke(cli, ["stash", "--json"], env=env)
615 if r_push.exit_code != 0:
616 failures.append(f"push {i}: {r_push.output.strip()[:60]}")
617 continue
618 r_pop = runner.invoke(cli, ["stash", "pop", "--json"], env=env)
619 if r_pop.exit_code != 0:
620 failures.append(f"pop {i}: {r_pop.output.strip()[:60]}")
621
622 assert not failures, f"Failures: {failures}"
623
624 @pytest.mark.slow
625 def test_large_stash_stack(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
626 """Stack of 50 entries then clear all via drop loop."""
627 monkeypatch.chdir(tmp_path)
628 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
629 env = _env(tmp_path)
630 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
631 (tmp_path / "base.py").write_text("base\n")
632 runner.invoke(cli, ["commit", "-m", "base"], env=env, catch_exceptions=False)
633
634 # Push 50 entries
635 for i in range(50):
636 (tmp_path / f"f{i}.py").write_text(f"file {i}\n")
637 runner.invoke(cli, ["stash", "-m", f"entry {i}"], env=env, catch_exceptions=False)
638
639 entries = json.loads(runner.invoke(cli, ["stash", "list", "--json"], env=env, catch_exceptions=False).output)
640 assert len(entries) == 50
641
642 # Drop all — always drop index 0 (newest)
643 for _ in range(50):
644 r = runner.invoke(cli, ["stash", "drop", "0"], env=env)
645 assert r.exit_code == 0
646
647 entries_after = json.loads(runner.invoke(cli, ["stash", "list", "--json"], env=env, catch_exceptions=False).output)
648 assert entries_after == []
649
650 @pytest.mark.slow
651 def test_concurrent_stash_isolated_repos(self, tmp_path: pathlib.Path) -> None:
652 """Parallel stash operations to distinct repos must not interfere.
653
654 Uses subprocess.run for true env isolation — CliRunner shares os.environ
655 globally and is not safe for concurrent use across threads.
656 """
657 import subprocess
658 import sys
659 errors: list[str] = []
660
661 muse_bin = pathlib.Path(sys.executable).parent / "muse"
662
663 def do_stash(idx: int) -> None:
664 repo_dir = tmp_path / f"repo_{idx}"
665 repo_dir.mkdir()
666 env = {**os.environ, "MUSE_REPO_ROOT": str(repo_dir)}
667
668 def run(*args: str) -> subprocess.CompletedProcess[str]:
669 return subprocess.run(
670 [str(muse_bin), *args],
671 capture_output=True, text=True, env=env, cwd=str(repo_dir),
672 )
673
674 run("init")
675 (repo_dir / "base.py").write_text("base\n")
676 run("commit", "-m", "base")
677 (repo_dir / "work.py").write_text(f"work {idx}\n")
678 r_push = run("stash", "--json")
679 if r_push.returncode != 0:
680 errors.append(f"repo {idx} push: {(r_push.stdout + r_push.stderr).strip()[:80]}")
681 return
682 r_pop = run("stash", "pop", "--json")
683 if r_pop.returncode != 0:
684 errors.append(f"repo {idx} pop: {(r_pop.stdout + r_pop.stderr).strip()[:80]}")
685
686 threads = [threading.Thread(target=do_stash, args=(i,)) for i in range(10)]
687 for t in threads:
688 t.start()
689 for t in threads:
690 t.join()
691
692 assert not errors, f"Concurrent stash errors: {errors}"
693
694 @pytest.mark.slow
695 def test_show_performance(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
696 """stash show on a 50-entry stack must complete in < 1 s."""
697 monkeypatch.chdir(tmp_path)
698 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
699 env = _env(tmp_path)
700 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
701 (tmp_path / "base.py").write_text("base\n")
702 runner.invoke(cli, ["commit", "-m", "base"], env=env, catch_exceptions=False)
703 for i in range(50):
704 (tmp_path / f"f{i}.py").write_text(f"{i}\n")
705 runner.invoke(cli, ["stash"], env=env, catch_exceptions=False)
706
707 start = time.perf_counter()
708 r = runner.invoke(cli, ["stash", "show", "49", "--json"], env=env, catch_exceptions=False)
709 elapsed = time.perf_counter() - start
710 assert r.exit_code == 0, r.output
711 assert elapsed < 1.0, f"stash show took {elapsed:.2f}s"
712
713
714 # ---------------------------------------------------------------------------
715 # Extended — muse stash pop
716 # ---------------------------------------------------------------------------
717
718 class TestStashPopExtended:
719 """Extended integration tests for ``muse stash pop``."""
720
721 def test_pop_j_alias(self, stashed_repo: pathlib.Path) -> None:
722 """-j must be an alias for --json."""
723 r = runner.invoke(cli, ["stash", "pop", "-j"], env=_env(stashed_repo), catch_exceptions=False)
724 assert r.exit_code == 0, r.output
725 d = json.loads(r.output)
726 assert d["status"] == "popped"
727
728 def test_pop_decrements_stash_size(self, repo: pathlib.Path) -> None:
729 """Stash size must decrease by exactly 1 after pop."""
730 # Push two entries
731 (repo / "c.py").write_text("c\n")
732 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
733 (repo / "d.py").write_text("d\n")
734 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
735 before = json.loads(runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False).output)
736 r = runner.invoke(cli, ["stash", "pop", "--json"], env=_env(repo), catch_exceptions=False)
737 d = json.loads(r.output)
738 assert d["stash_size_after"] == len(before) - 1
739
740 def test_pop_removes_specific_index(self, repo: pathlib.Path) -> None:
741 """Popping index 1 must leave index 0 and index 2 entries intact."""
742 for i in range(3):
743 (repo / f"f{i}.py").write_text(f"{i}\n")
744 runner.invoke(cli, ["stash", "-m", f"entry{i}"], env=_env(repo), catch_exceptions=False)
745 r = runner.invoke(cli, ["stash", "pop", "1", "--json"], env=_env(repo), catch_exceptions=False)
746 assert r.exit_code == 0, r.output
747 entries = json.loads(runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False).output)
748 assert len(entries) == 2
749 assert entries[0]["message"] == "entry2"
750 assert entries[1]["message"] == "entry0"
751
752 def test_pop_last_entry_leaves_empty_stack(self, stashed_repo: pathlib.Path) -> None:
753 runner.invoke(cli, ["stash", "pop"], env=_env(stashed_repo), catch_exceptions=False)
754 entries = json.loads(runner.invoke(cli, ["stash", "list", "--json"], env=_env(stashed_repo), catch_exceptions=False).output)
755 assert entries == []
756
757 def test_pop_restores_file_content(self, repo: pathlib.Path) -> None:
758 """Content of restored file must be byte-for-byte identical."""
759 content = "exact content 12345\n"
760 (repo / "b.py").write_text(content)
761 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
762 runner.invoke(cli, ["stash", "pop"], env=_env(repo), catch_exceptions=False)
763 assert (repo / "b.py").read_text() == content
764
765 def test_pop_json_message_null_when_none(self, stashed_repo: pathlib.Path) -> None:
766 r = runner.invoke(cli, ["stash", "pop", "--json"], env=_env(stashed_repo), catch_exceptions=False)
767 d = json.loads(r.output)
768 assert d["message"] is None
769
770 def test_pop_json_message_present_when_annotated(self, repo: pathlib.Path) -> None:
771 runner.invoke(cli, ["stash", "-m", "my work"], env=_env(repo), catch_exceptions=False)
772 r = runner.invoke(cli, ["stash", "pop", "--json"], env=_env(repo), catch_exceptions=False)
773 d = json.loads(r.output)
774 assert d["message"] == "my work"
775
776 def test_pop_text_output_contains_branch(self, stashed_repo: pathlib.Path) -> None:
777 r = runner.invoke(cli, ["stash", "pop"], env=_env(stashed_repo), catch_exceptions=False)
778 assert "main" in r.output
779
780 def test_pop_text_output_contains_stash_at(self, stashed_repo: pathlib.Path) -> None:
781 r = runner.invoke(cli, ["stash", "pop"], env=_env(stashed_repo), catch_exceptions=False)
782 assert "stash@{" in r.output
783
784 def test_pop_text_output_contains_annotation(self, repo: pathlib.Path) -> None:
785 runner.invoke(cli, ["stash", "-m", "annotated-work"], env=_env(repo), catch_exceptions=False)
786 r = runner.invoke(cli, ["stash", "pop"], env=_env(repo), catch_exceptions=False)
787 assert "annotated-work" in r.output
788
789 def test_pop_json_snapshot_id_is_sha256(self, stashed_repo: pathlib.Path) -> None:
790 r = runner.invoke(cli, ["stash", "pop", "--json"], env=_env(stashed_repo), catch_exceptions=False)
791 d = json.loads(r.output)
792 assert isinstance(d["snapshot_id"], str) and len(d["snapshot_id"]) == 64
793
794 def test_pop_json_files_count_positive(self, stashed_repo: pathlib.Path) -> None:
795 r = runner.invoke(cli, ["stash", "pop", "--json"], env=_env(stashed_repo), catch_exceptions=False)
796 d = json.loads(r.output)
797 assert d["files_count"] > 0
798
799 def test_pop_json_stash_size_after_non_negative(self, stashed_repo: pathlib.Path) -> None:
800 r = runner.invoke(cli, ["stash", "pop", "--json"], env=_env(stashed_repo), catch_exceptions=False)
801 d = json.loads(r.output)
802 assert d["stash_size_after"] >= 0
803
804 def test_pop_removes_entry_from_disk(self, stashed_repo: pathlib.Path) -> None:
805 """After pop, stash.json must not contain the popped entry."""
806 before = json.loads((stashed_repo / ".muse/stash.json").read_text())
807 snap_id = before[0]["snapshot_id"]
808 runner.invoke(cli, ["stash", "pop"], env=_env(stashed_repo), catch_exceptions=False)
809 stash_path = stashed_repo / ".muse/stash.json"
810 if stash_path.exists():
811 after = json.loads(stash_path.read_text())
812 assert all(e["snapshot_id"] != snap_id for e in after)
813
814 def test_pop_default_format_is_text(self, stashed_repo: pathlib.Path) -> None:
815 """Default output must not be valid JSON (i.e., must be text)."""
816 r = runner.invoke(cli, ["stash", "pop"], env=_env(stashed_repo), catch_exceptions=False)
817 assert r.exit_code == 0
818 try:
819 json.loads(r.output)
820 assert False, "default output should be text, not JSON"
821 except (json.JSONDecodeError, ValueError):
822 pass # expected: text output
823
824 def test_pop_index_zero_explicit(self, stashed_repo: pathlib.Path) -> None:
825 r = runner.invoke(cli, ["stash", "pop", "0", "--json"], env=_env(stashed_repo), catch_exceptions=False)
826 assert r.exit_code == 0, r.output
827 assert json.loads(r.output)["status"] == "popped"
828
829 def test_pop_error_no_entries_exits_1(self, repo: pathlib.Path) -> None:
830 r = runner.invoke(cli, ["stash", "pop"], env=_env(repo))
831 assert r.exit_code == 1
832
833 def test_pop_error_out_of_range_exits_1(self, stashed_repo: pathlib.Path) -> None:
834 r = runner.invoke(cli, ["stash", "pop", "99"], env=_env(stashed_repo))
835 assert r.exit_code == 1
836
837 def test_pop_error_non_integer_index_exits_1(self, stashed_repo: pathlib.Path) -> None:
838 r = runner.invoke(cli, ["stash", "pop", "not_a_number"], env=_env(stashed_repo))
839 assert r.exit_code == 1
840
841 def test_pop_json_empty_error_schema(self, repo: pathlib.Path) -> None:
842 """pop --json on empty stash must emit a JSON error object on stdout."""
843 r = runner.invoke(cli, ["stash", "pop", "--json"], env=_env(repo))
844 assert r.exit_code != 0
845 # CliRunner combines stdout+stderr; find the JSON line in output.
846 json_lines = [l for l in r.output.splitlines() if l.strip().startswith("{")]
847 assert json_lines, f"No JSON line in output: {r.output!r}"
848 d = json.loads(json_lines[0])
849 assert "error" in d
850 assert d["stash_size"] == 0
851
852 def test_pop_branch_field_matches_current_branch(self, stashed_repo: pathlib.Path) -> None:
853 r = runner.invoke(cli, ["stash", "pop", "--json"], env=_env(stashed_repo), catch_exceptions=False)
854 d = json.loads(r.output)
855 assert d["branch"] == "main"
856
857 def test_pop_stashed_at_is_iso8601(self, stashed_repo: pathlib.Path) -> None:
858 import datetime as _dt
859 r = runner.invoke(cli, ["stash", "pop", "--json"], env=_env(stashed_repo), catch_exceptions=False)
860 d = json.loads(r.output)
861 ts = _dt.datetime.fromisoformat(d["stashed_at"])
862 assert ts.tzinfo is not None, "stashed_at must be timezone-aware"
863
864 def test_pop_description_in_help(self) -> None:
865 """pop --help must include description text."""
866 r = runner.invoke(cli, ["stash", "pop", "--help"], env={})
867 assert r.exit_code == 0
868 assert "Index rules" in r.output or "stash@{0}" in r.output
869
870 def test_pop_outside_repo_exits_2(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
871 monkeypatch.chdir(tmp_path)
872 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
873 r = runner.invoke(cli, ["stash", "pop"])
874 assert r.exit_code == 2
875
876
877 # ---------------------------------------------------------------------------
878 # Security — muse stash pop
879 # ---------------------------------------------------------------------------
880
881 class TestStashPopSecurity:
882 """Security tests for ``muse stash pop``."""
883
884 def test_ansi_in_branch_sanitized_in_pop_text(self, repo: pathlib.Path) -> None:
885 """Branch name with ANSI codes must not appear raw in pop text output."""
886 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
887 stash_path = repo / ".muse/stash.json"
888 raw = json.loads(stash_path.read_text())
889 raw[0]["branch"] = "\x1b[31mmain\x1b[0m"
890 stash_path.write_text(json.dumps(raw))
891 r = runner.invoke(cli, ["stash", "pop"], env=_env(repo), catch_exceptions=False)
892 assert "\x1b[31m" not in r.output
893
894 def test_ansi_in_message_sanitized_in_pop_text(self, repo: pathlib.Path) -> None:
895 """Message with ANSI codes must not appear raw in pop text output."""
896 runner.invoke(cli, ["stash", "-m", "\x1b[32mwip\x1b[0m"], env=_env(repo), catch_exceptions=False)
897 r = runner.invoke(cli, ["stash", "pop"], env=_env(repo), catch_exceptions=False)
898 assert "\x1b[32m" not in r.output
899
900 def test_invalid_format_exits_1_to_stderr(self, stashed_repo: pathlib.Path) -> None:
901 r = runner.invoke(cli, ["stash", "pop", "--format", "xml"], env=_env(stashed_repo))
902 assert r.exit_code == 1
903 assert "xml" in (r.stderr or r.output).lower()
904
905 def test_missing_object_error_to_stderr(self, stashed_repo: pathlib.Path) -> None:
906 """Missing-object error message must go to stderr."""
907 stash_data = json.loads((stashed_repo / ".muse/stash.json").read_text())
908 manifest = stash_data[0]["delta"]
909 for obj_id in list(manifest.values())[:1]:
910 obj_path = stashed_repo / ".muse" / "objects" / obj_id[:2] / obj_id[2:]
911 if obj_path.exists():
912 obj_path.unlink()
913 break
914 r = runner.invoke(cli, ["stash", "pop"], env=_env(stashed_repo))
915 assert r.exit_code == 3
916 assert "missing" in (r.stderr or "").lower() or "missing" in r.output.lower()
917
918 def test_pop_index_zero_string_not_special(self, stashed_repo: pathlib.Path) -> None:
919 """'0' as index must be treated as integer 0, not a path or injection."""
920 r = runner.invoke(cli, ["stash", "pop", "0", "--json"], env=_env(stashed_repo), catch_exceptions=False)
921 assert r.exit_code == 0
922
923 def test_pop_index_with_whitespace_rejected(self, stashed_repo: pathlib.Path) -> None:
924 """Index with leading space must not crash — either rejected or treated as integer."""
925 r = runner.invoke(cli, ["stash", "pop", " 0"], env=_env(stashed_repo))
926 # argparse may strip whitespace or pass " 0" verbatim; _resolve_index
927 # must handle it without an unhandled exception (no traceback in output).
928 assert "Traceback" not in r.output
929
930 def test_pop_large_index_bounded(self, stashed_repo: pathlib.Path) -> None:
931 """Arbitrarily large index must exit with USER_ERROR, not an exception."""
932 r = runner.invoke(cli, ["stash", "pop", "999999999999"])
933 assert r.exit_code == 1
934
935 def test_pop_negative_index_rejected(self, stashed_repo: pathlib.Path) -> None:
936 """Negative index must be rejected cleanly, not used as Python slice."""
937 r = runner.invoke(cli, ["stash", "pop", "-1"], env=_env(stashed_repo))
938 # -1 may be parsed as a flag by argparse; either way, must not silently
939 # pop the last entry using Python negative indexing.
940 if r.exit_code == 0:
941 # If argparse accepted it, verify it did NOT use Python negative indexing
942 # (i.e., must not have popped the last entry silently as stash@{-1})
943 pass # argparse-level rejection is also acceptable
944 else:
945 assert r.exit_code == 1
946
947
948 # ---------------------------------------------------------------------------
949 # Stress — muse stash pop
950 # ---------------------------------------------------------------------------
951
952 class TestStashPopStress:
953 """Stress tests for ``muse stash pop``."""
954
955 @pytest.mark.slow
956 def test_pop_performance_large_stash(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
957 """Pop from a 50-entry stash stack must complete in < 2 s."""
958 monkeypatch.chdir(tmp_path)
959 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
960 env = _env(tmp_path)
961 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
962 (tmp_path / "base.py").write_text("base\n")
963 runner.invoke(cli, ["commit", "-m", "base"], env=env, catch_exceptions=False)
964 for i in range(50):
965 (tmp_path / f"f{i}.py").write_text(f"{i}\n")
966 runner.invoke(cli, ["stash"], env=env, catch_exceptions=False)
967
968 start = time.perf_counter()
969 r = runner.invoke(cli, ["stash", "pop", "49", "--json"], env=env, catch_exceptions=False)
970 elapsed = time.perf_counter() - start
971 assert r.exit_code == 0, r.output
972 assert elapsed < 2.0, f"pop took {elapsed:.2f}s"
973
974 @pytest.mark.slow
975 def test_pop_all_entries_sequential(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
976 """Pop all 20 entries one-by-one; stash must be empty after."""
977 monkeypatch.chdir(tmp_path)
978 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
979 env = _env(tmp_path)
980 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
981 (tmp_path / "base.py").write_text("base\n")
982 runner.invoke(cli, ["commit", "-m", "base"], env=env, catch_exceptions=False)
983
984 for i in range(20):
985 (tmp_path / f"w{i}.py").write_text(f"data {i}\n")
986 runner.invoke(cli, ["stash"], env=env, catch_exceptions=False)
987
988 failures: list[str] = []
989 for i in range(20):
990 r = runner.invoke(cli, ["stash", "pop", "--json"], env=env)
991 if r.exit_code != 0:
992 failures.append(f"pop {i}: exit={r.exit_code} out={r.output.strip()[:60]}")
993 assert not failures, f"Pop failures: {failures}"
994
995 entries = json.loads(runner.invoke(cli, ["stash", "list", "--json"], env=env, catch_exceptions=False).output)
996 assert entries == []
997
998 @pytest.mark.slow
999 def test_pop_concurrent_isolated_repos(self, tmp_path: pathlib.Path) -> None:
1000 """Concurrent pops in isolated repos must not interfere.
1001
1002 Uses subprocess.run for true env isolation — CliRunner shares os.environ
1003 globally and is not safe for concurrent use across threads.
1004 """
1005 import subprocess
1006 import sys
1007 errors: list[str] = []
1008
1009 muse_bin = pathlib.Path(sys.executable).parent / "muse"
1010
1011 def do_push_pop(idx: int) -> None:
1012 repo_dir = tmp_path / f"repo_{idx}"
1013 repo_dir.mkdir()
1014 env = {**os.environ, "MUSE_REPO_ROOT": str(repo_dir)}
1015
1016 def run(*args: str) -> subprocess.CompletedProcess[str]:
1017 return subprocess.run(
1018 [str(muse_bin), *args],
1019 capture_output=True, text=True, env=env, cwd=str(repo_dir),
1020 )
1021
1022 run("init")
1023 (repo_dir / "base.py").write_text("base\n")
1024 run("commit", "-m", "base")
1025 (repo_dir / "work.py").write_text(f"work {idx}\n")
1026 r_push = run("stash", "--json")
1027 if r_push.returncode != 0:
1028 errors.append(f"repo {idx} push failed: {(r_push.stdout + r_push.stderr).strip()[:80]}")
1029 return
1030 r_pop = run("stash", "pop", "--json")
1031 if r_pop.returncode != 0:
1032 errors.append(f"repo {idx} pop failed: {(r_pop.stdout + r_pop.stderr).strip()[:80]}")
1033 return
1034 try:
1035 d = json.loads(r_pop.stdout)
1036 except json.JSONDecodeError:
1037 errors.append(f"repo {idx} non-JSON pop output: {r_pop.stdout.strip()[:60]}")
1038 return
1039 if d.get("status") != "popped":
1040 errors.append(f"repo {idx} wrong status: {d.get('status')}")
1041
1042 threads = [threading.Thread(target=do_push_pop, args=(i,)) for i in range(10)]
1043 for t in threads:
1044 t.start()
1045 for t in threads:
1046 t.join()
1047
1048 assert not errors, f"Concurrent pop errors: {errors}"
1049
1050
1051 # ---------------------------------------------------------------------------
1052 # Extended — muse stash apply
1053 # ---------------------------------------------------------------------------
1054
1055 class TestStashApplyExtended:
1056 """Extended integration tests for ``muse stash apply``."""
1057
1058 def test_apply_j_alias(self, stashed_repo: pathlib.Path) -> None:
1059 """-j must be an alias for --json."""
1060 r = runner.invoke(cli, ["stash", "apply", "-j"], env=_env(stashed_repo), catch_exceptions=False)
1061 assert r.exit_code == 0, r.output
1062 d = json.loads(r.output)
1063 assert d["status"] == "applied"
1064
1065 def test_apply_preserves_all_entries(self, repo: pathlib.Path) -> None:
1066 """apply must not remove any stash entry."""
1067 for i in range(3):
1068 (repo / f"f{i}.py").write_text(f"{i}\n")
1069 runner.invoke(cli, ["stash", "-m", f"e{i}"], env=_env(repo), catch_exceptions=False)
1070 before = json.loads(runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False).output)
1071 runner.invoke(cli, ["stash", "apply", "1", "--json"], env=_env(repo), catch_exceptions=False)
1072 after = json.loads(runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False).output)
1073 assert len(before) == len(after)
1074
1075 def test_apply_stash_size_unchanged(self, stashed_repo: pathlib.Path) -> None:
1076 r = runner.invoke(cli, ["stash", "apply", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1077 d = json.loads(r.output)
1078 assert d["stash_size"] == 1
1079
1080 def test_apply_restores_file_content(self, repo: pathlib.Path) -> None:
1081 content = "precise content 99\n"
1082 (repo / "b.py").write_text(content)
1083 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
1084 runner.invoke(cli, ["stash", "apply"], env=_env(repo), catch_exceptions=False)
1085 assert (repo / "b.py").read_text() == content
1086
1087 def test_apply_by_index(self, repo: pathlib.Path) -> None:
1088 """Applying entry at index 1 must restore that entry's files."""
1089 (repo / "b.py").write_text("first\n")
1090 r0 = runner.invoke(cli, ["stash", "-m", "first", "--json"], env=_env(repo), catch_exceptions=False)
1091 snap0 = json.loads(r0.output)["snapshot_id"]
1092 (repo / "b.py").write_text("second\n")
1093 runner.invoke(cli, ["stash", "-m", "second"], env=_env(repo), catch_exceptions=False)
1094 r = runner.invoke(cli, ["stash", "apply", "1", "--json"], env=_env(repo), catch_exceptions=False)
1095 d = json.loads(r.output)
1096 assert d["snapshot_id"] == snap0
1097
1098 def test_apply_json_status_is_applied(self, stashed_repo: pathlib.Path) -> None:
1099 r = runner.invoke(cli, ["stash", "apply", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1100 assert json.loads(r.output)["status"] == "applied"
1101
1102 def test_apply_json_message_null_when_none(self, stashed_repo: pathlib.Path) -> None:
1103 r = runner.invoke(cli, ["stash", "apply", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1104 assert json.loads(r.output)["message"] is None
1105
1106 def test_apply_json_message_when_annotated(self, repo: pathlib.Path) -> None:
1107 runner.invoke(cli, ["stash", "-m", "wip stuff"], env=_env(repo), catch_exceptions=False)
1108 r = runner.invoke(cli, ["stash", "apply", "--json"], env=_env(repo), catch_exceptions=False)
1109 assert json.loads(r.output)["message"] == "wip stuff"
1110
1111 def test_apply_json_snapshot_id_is_sha256(self, stashed_repo: pathlib.Path) -> None:
1112 r = runner.invoke(cli, ["stash", "apply", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1113 snap = json.loads(r.output)["snapshot_id"]
1114 assert isinstance(snap, str) and len(snap) == 64
1115
1116 def test_apply_json_files_count_positive(self, stashed_repo: pathlib.Path) -> None:
1117 r = runner.invoke(cli, ["stash", "apply", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1118 assert json.loads(r.output)["files_count"] > 0
1119
1120 def test_apply_json_branch_field(self, stashed_repo: pathlib.Path) -> None:
1121 r = runner.invoke(cli, ["stash", "apply", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1122 assert json.loads(r.output)["branch"] == "main"
1123
1124 def test_apply_json_stashed_at_is_iso8601(self, stashed_repo: pathlib.Path) -> None:
1125 import datetime as _dt
1126 r = runner.invoke(cli, ["stash", "apply", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1127 ts = _dt.datetime.fromisoformat(json.loads(r.output)["stashed_at"])
1128 assert ts.tzinfo is not None
1129
1130 def test_apply_text_contains_branch(self, stashed_repo: pathlib.Path) -> None:
1131 r = runner.invoke(cli, ["stash", "apply"], env=_env(stashed_repo), catch_exceptions=False)
1132 assert "main" in r.output
1133
1134 def test_apply_text_contains_stash_at(self, stashed_repo: pathlib.Path) -> None:
1135 r = runner.invoke(cli, ["stash", "apply"], env=_env(stashed_repo), catch_exceptions=False)
1136 assert "stash@{" in r.output
1137
1138 def test_apply_text_says_preserved(self, stashed_repo: pathlib.Path) -> None:
1139 r = runner.invoke(cli, ["stash", "apply"], env=_env(stashed_repo), catch_exceptions=False)
1140 assert "preserved" in r.output.lower()
1141
1142 def test_apply_text_contains_annotation(self, repo: pathlib.Path) -> None:
1143 runner.invoke(cli, ["stash", "-m", "keep-this"], env=_env(repo), catch_exceptions=False)
1144 r = runner.invoke(cli, ["stash", "apply"], env=_env(repo), catch_exceptions=False)
1145 assert "keep-this" in r.output
1146
1147 def test_apply_default_format_is_text(self, stashed_repo: pathlib.Path) -> None:
1148 r = runner.invoke(cli, ["stash", "apply"], env=_env(stashed_repo), catch_exceptions=False)
1149 try:
1150 json.loads(r.output)
1151 assert False, "default output should be text, not JSON"
1152 except (json.JSONDecodeError, ValueError):
1153 pass
1154
1155 def test_apply_empty_exits_1(self, repo: pathlib.Path) -> None:
1156 r = runner.invoke(cli, ["stash", "apply"], env=_env(repo))
1157 assert r.exit_code == 1
1158
1159 def test_apply_out_of_range_exits_1(self, stashed_repo: pathlib.Path) -> None:
1160 r = runner.invoke(cli, ["stash", "apply", "99"], env=_env(stashed_repo))
1161 assert r.exit_code == 1
1162
1163 def test_apply_non_integer_index_exits_1(self, stashed_repo: pathlib.Path) -> None:
1164 r = runner.invoke(cli, ["stash", "apply", "abc"], env=_env(stashed_repo))
1165 assert r.exit_code == 1
1166
1167 def test_apply_json_empty_error_schema(self, repo: pathlib.Path) -> None:
1168 r = runner.invoke(cli, ["stash", "apply", "--json"], env=_env(repo))
1169 assert r.exit_code != 0
1170 json_lines = [l for l in r.output.splitlines() if l.strip().startswith("{")]
1171 assert json_lines, f"No JSON in output: {r.output!r}"
1172 d = json.loads(json_lines[0])
1173 assert "error" in d
1174 assert d["stash_size"] == 0
1175
1176 def test_apply_twice_idempotent_stash_size(self, repo: pathlib.Path) -> None:
1177 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
1178 runner.invoke(cli, ["stash", "apply"], env=_env(repo), catch_exceptions=False)
1179 runner.invoke(cli, ["stash", "apply"], env=_env(repo), catch_exceptions=False)
1180 entries = json.loads(runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False).output)
1181 assert len(entries) == 1
1182
1183 def test_apply_description_in_help(self) -> None:
1184 r = runner.invoke(cli, ["stash", "apply", "--help"], env={})
1185 assert r.exit_code == 0
1186 assert "Index rules" in r.output or "stash@{0}" in r.output
1187
1188 def test_apply_outside_repo_exits_2(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
1189 monkeypatch.chdir(tmp_path)
1190 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
1191 r = runner.invoke(cli, ["stash", "apply"])
1192 assert r.exit_code == 2
1193
1194 def test_apply_schema_complete(self, stashed_repo: pathlib.Path) -> None:
1195 _REQUIRED = {"status", "snapshot_id", "branch", "stashed_at", "message", "files_count", "stash_size"}
1196 r = runner.invoke(cli, ["stash", "apply", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1197 assert _REQUIRED <= json.loads(r.output).keys()
1198
1199
1200 # ---------------------------------------------------------------------------
1201 # Security — muse stash apply
1202 # ---------------------------------------------------------------------------
1203
1204 class TestStashApplySecurity:
1205 """Security tests for ``muse stash apply``."""
1206
1207 def test_ansi_in_branch_sanitized(self, repo: pathlib.Path) -> None:
1208 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
1209 stash_path = repo / ".muse/stash.json"
1210 raw = json.loads(stash_path.read_text())
1211 raw[0]["branch"] = "\x1b[31mmain\x1b[0m"
1212 stash_path.write_text(json.dumps(raw))
1213 r = runner.invoke(cli, ["stash", "apply"], env=_env(repo), catch_exceptions=False)
1214 assert "\x1b[31m" not in r.output
1215
1216 def test_ansi_in_message_sanitized(self, repo: pathlib.Path) -> None:
1217 runner.invoke(cli, ["stash", "-m", "\x1b[32mwip\x1b[0m"], env=_env(repo), catch_exceptions=False)
1218 r = runner.invoke(cli, ["stash", "apply"], env=_env(repo), catch_exceptions=False)
1219 assert "\x1b[32m" not in r.output
1220
1221 def test_invalid_format_exits_1(self, stashed_repo: pathlib.Path) -> None:
1222 r = runner.invoke(cli, ["stash", "apply", "--format", "xml"], env=_env(stashed_repo))
1223 assert r.exit_code == 1
1224 assert "xml" in (r.stderr or r.output).lower()
1225
1226 def test_missing_object_listed_in_stderr(self, stashed_repo: pathlib.Path) -> None:
1227 """Each missing path must be listed in stderr output."""
1228 stash_data = json.loads((stashed_repo / ".muse/stash.json").read_text())
1229 manifest = stash_data[0]["delta"]
1230 for obj_id in list(manifest.values())[:1]:
1231 obj_path = stashed_repo / ".muse" / "objects" / obj_id[:2] / obj_id[2:]
1232 if obj_path.exists():
1233 obj_path.unlink()
1234 break
1235 r = runner.invoke(cli, ["stash", "apply"], env=_env(stashed_repo))
1236 assert r.exit_code == 3
1237 assert "missing" in (r.stderr or "").lower() or "missing" in r.output.lower()
1238
1239 def test_missing_object_exits_3(self, stashed_repo: pathlib.Path) -> None:
1240 stash_data = json.loads((stashed_repo / ".muse/stash.json").read_text())
1241 for obj_id in list(stash_data[0]["delta"].values())[:1]:
1242 obj_path = stashed_repo / ".muse" / "objects" / obj_id[:2] / obj_id[2:]
1243 if obj_path.exists():
1244 obj_path.unlink()
1245 break
1246 r = runner.invoke(cli, ["stash", "apply"], env=_env(stashed_repo))
1247 assert r.exit_code == 3
1248
1249 def test_apply_does_not_modify_stash_on_success(self, stashed_repo: pathlib.Path) -> None:
1250 """apply must not write stash.json."""
1251 stash_path = stashed_repo / ".muse/stash.json"
1252 before_mtime = stash_path.stat().st_mtime
1253 runner.invoke(cli, ["stash", "apply"], env=_env(stashed_repo), catch_exceptions=False)
1254 after_mtime = stash_path.stat().st_mtime
1255 assert before_mtime == after_mtime, "apply must not modify stash.json"
1256
1257 def test_large_index_rejected(self, stashed_repo: pathlib.Path) -> None:
1258 r = runner.invoke(cli, ["stash", "apply", "999999999"], env=_env(stashed_repo))
1259 assert r.exit_code == 1
1260
1261 def test_negative_index_rejected(self, stashed_repo: pathlib.Path) -> None:
1262 r = runner.invoke(cli, ["stash", "apply", "-1"], env=_env(stashed_repo))
1263 assert r.exit_code != 0 or "Traceback" not in r.output
1264
1265
1266 # ---------------------------------------------------------------------------
1267 # Stress — muse stash apply
1268 # ---------------------------------------------------------------------------
1269
1270 class TestStashApplyStress:
1271 """Stress tests for ``muse stash apply``."""
1272
1273 @pytest.mark.slow
1274 def test_apply_performance_large_stash(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
1275 """apply on a 50-entry stack must complete in < 2 s."""
1276 monkeypatch.chdir(tmp_path)
1277 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
1278 env = _env(tmp_path)
1279 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
1280 (tmp_path / "base.py").write_text("base\n")
1281 runner.invoke(cli, ["commit", "-m", "base"], env=env, catch_exceptions=False)
1282 for i in range(50):
1283 (tmp_path / f"f{i}.py").write_text(f"{i}\n")
1284 runner.invoke(cli, ["stash"], env=env, catch_exceptions=False)
1285
1286 start = time.perf_counter()
1287 r = runner.invoke(cli, ["stash", "apply", "49", "--json"], env=env, catch_exceptions=False)
1288 elapsed = time.perf_counter() - start
1289 assert r.exit_code == 0, r.output
1290 assert elapsed < 2.0, f"apply took {elapsed:.2f}s"
1291
1292 @pytest.mark.slow
1293 def test_apply_repeated_idempotent(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
1294 """Applying the same entry 20 times must leave stash size unchanged."""
1295 monkeypatch.chdir(tmp_path)
1296 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
1297 env = _env(tmp_path)
1298 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
1299 (tmp_path / "base.py").write_text("base\n")
1300 runner.invoke(cli, ["commit", "-m", "base"], env=env, catch_exceptions=False)
1301 (tmp_path / "work.py").write_text("data\n")
1302 runner.invoke(cli, ["stash"], env=env, catch_exceptions=False)
1303
1304 failures: list[str] = []
1305 for i in range(20):
1306 r = runner.invoke(cli, ["stash", "apply", "--json"], env=env)
1307 if r.exit_code != 0:
1308 failures.append(f"apply {i}: {r.output.strip()[:60]}")
1309 assert not failures
1310
1311 entries = json.loads(runner.invoke(cli, ["stash", "list", "--json"], env=env, catch_exceptions=False).output)
1312 assert len(entries) == 1
1313
1314 @pytest.mark.slow
1315 def test_apply_concurrent_isolated_repos(self, tmp_path: pathlib.Path) -> None:
1316 """Concurrent applies in isolated repos must not interfere."""
1317 import subprocess
1318 import sys
1319 errors: list[str] = []
1320 muse_bin = pathlib.Path(sys.executable).parent / "muse"
1321
1322 def do_apply(idx: int) -> None:
1323 repo_dir = tmp_path / f"repo_{idx}"
1324 repo_dir.mkdir()
1325 env = {**os.environ, "MUSE_REPO_ROOT": str(repo_dir)}
1326
1327 def run(*args: str) -> subprocess.CompletedProcess[str]:
1328 return subprocess.run(
1329 [str(muse_bin), *args],
1330 capture_output=True, text=True, env=env, cwd=str(repo_dir),
1331 )
1332
1333 run("init")
1334 (repo_dir / "base.py").write_text("base\n")
1335 run("commit", "-m", "base")
1336 (repo_dir / "work.py").write_text(f"work {idx}\n")
1337 run("stash")
1338 r = run("stash", "apply", "--json")
1339 if r.returncode != 0:
1340 errors.append(f"repo {idx} apply failed: {(r.stdout + r.stderr).strip()[:80]}")
1341 return
1342 try:
1343 d = json.loads(r.stdout)
1344 except json.JSONDecodeError:
1345 errors.append(f"repo {idx} non-JSON: {r.stdout.strip()[:60]}")
1346 return
1347 if d.get("status") != "applied":
1348 errors.append(f"repo {idx} wrong status: {d.get('status')}")
1349
1350 threads = [threading.Thread(target=do_apply, args=(i,)) for i in range(10)]
1351 for t in threads:
1352 t.start()
1353 for t in threads:
1354 t.join()
1355
1356 assert not errors, f"Concurrent apply errors: {errors}"
1357
1358
1359 # ---------------------------------------------------------------------------
1360 # Extended — muse stash show
1361 # ---------------------------------------------------------------------------
1362
1363 class TestStashShowExtended:
1364 """Extended integration tests for ``muse stash show``."""
1365
1366 def test_show_j_alias(self, stashed_repo: pathlib.Path) -> None:
1367 r = runner.invoke(cli, ["stash", "show", "-j"], env=_env(stashed_repo), catch_exceptions=False)
1368 assert r.exit_code == 0
1369 assert json.loads(r.output)["index"] == 0
1370
1371 def test_show_schema_includes_files_count(self, stashed_repo: pathlib.Path) -> None:
1372 r = runner.invoke(cli, ["stash", "show", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1373 d = json.loads(r.output)
1374 assert "files_count" in d
1375 assert d["files_count"] == len(d["files"])
1376
1377 def test_show_schema_complete(self, stashed_repo: pathlib.Path) -> None:
1378 _REQUIRED = {"index", "snapshot_id", "branch", "stashed_at", "message", "files_count", "files"}
1379 r = runner.invoke(cli, ["stash", "show", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1380 assert _REQUIRED <= json.loads(r.output).keys()
1381
1382 def test_show_files_is_sorted(self, repo: pathlib.Path) -> None:
1383 for name in ["z.py", "a.py", "m.py"]:
1384 (repo / name).write_text("x\n")
1385 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
1386 r = runner.invoke(cli, ["stash", "show", "--json"], env=_env(repo), catch_exceptions=False)
1387 files = json.loads(r.output)["files"]
1388 assert files == sorted(files)
1389
1390 def test_show_files_count_matches_files(self, repo: pathlib.Path) -> None:
1391 for name in ["a.py", "b.py", "c.py"]:
1392 (repo / name).write_text("x\n")
1393 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
1394 r = runner.invoke(cli, ["stash", "show", "--json"], env=_env(repo), catch_exceptions=False)
1395 d = json.loads(r.output)
1396 assert d["files_count"] == len(d["files"])
1397
1398 def test_show_by_index(self, repo: pathlib.Path) -> None:
1399 (repo / "b.py").write_text("first\n")
1400 runner.invoke(cli, ["stash", "-m", "first"], env=_env(repo), catch_exceptions=False)
1401 (repo / "b.py").write_text("second\n")
1402 runner.invoke(cli, ["stash", "-m", "second"], env=_env(repo), catch_exceptions=False)
1403 r = runner.invoke(cli, ["stash", "show", "1", "--json"], env=_env(repo), catch_exceptions=False)
1404 assert json.loads(r.output)["index"] == 1
1405
1406 def test_show_index_zero_default(self, stashed_repo: pathlib.Path) -> None:
1407 r = runner.invoke(cli, ["stash", "show", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1408 assert json.loads(r.output)["index"] == 0
1409
1410 def test_show_does_not_modify_workdir(self, stashed_repo: pathlib.Path) -> None:
1411 before = set(p.name for p in stashed_repo.iterdir() if not p.name.startswith("."))
1412 runner.invoke(cli, ["stash", "show"], env=_env(stashed_repo), catch_exceptions=False)
1413 after = set(p.name for p in stashed_repo.iterdir() if not p.name.startswith("."))
1414 assert before == after
1415
1416 def test_show_does_not_modify_stash_json(self, stashed_repo: pathlib.Path) -> None:
1417 stash_path = stashed_repo / ".muse/stash.json"
1418 mtime_before = stash_path.stat().st_mtime
1419 runner.invoke(cli, ["stash", "show"], env=_env(stashed_repo), catch_exceptions=False)
1420 assert stash_path.stat().st_mtime == mtime_before
1421
1422 def test_show_message_null_when_none(self, stashed_repo: pathlib.Path) -> None:
1423 r = runner.invoke(cli, ["stash", "show", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1424 assert json.loads(r.output)["message"] is None
1425
1426 def test_show_message_when_annotated(self, repo: pathlib.Path) -> None:
1427 runner.invoke(cli, ["stash", "-m", "review me"], env=_env(repo), catch_exceptions=False)
1428 r = runner.invoke(cli, ["stash", "show", "--json"], env=_env(repo), catch_exceptions=False)
1429 assert json.loads(r.output)["message"] == "review me"
1430
1431 def test_show_snapshot_id_sha256(self, stashed_repo: pathlib.Path) -> None:
1432 r = runner.invoke(cli, ["stash", "show", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1433 snap = json.loads(r.output)["snapshot_id"]
1434 assert isinstance(snap, str) and len(snap) == 64
1435
1436 def test_show_stashed_at_iso8601(self, stashed_repo: pathlib.Path) -> None:
1437 import datetime as _dt
1438 r = runner.invoke(cli, ["stash", "show", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1439 ts = _dt.datetime.fromisoformat(json.loads(r.output)["stashed_at"])
1440 assert ts.tzinfo is not None
1441
1442 def test_show_branch_field(self, stashed_repo: pathlib.Path) -> None:
1443 r = runner.invoke(cli, ["stash", "show", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1444 assert json.loads(r.output)["branch"] == "main"
1445
1446 def test_show_text_header(self, stashed_repo: pathlib.Path) -> None:
1447 r = runner.invoke(cli, ["stash", "show"], env=_env(stashed_repo), catch_exceptions=False)
1448 assert "stash@{0}" in r.output
1449 assert "main" in r.output
1450
1451 def test_show_text_lists_files(self, repo: pathlib.Path) -> None:
1452 (repo / "b.py").write_text("data\n")
1453 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
1454 r = runner.invoke(cli, ["stash", "show"], env=_env(repo), catch_exceptions=False)
1455 assert "b.py" in r.output
1456
1457 def test_show_text_annotation(self, repo: pathlib.Path) -> None:
1458 runner.invoke(cli, ["stash", "-m", "my-note"], env=_env(repo), catch_exceptions=False)
1459 r = runner.invoke(cli, ["stash", "show"], env=_env(repo), catch_exceptions=False)
1460 assert "my-note" in r.output
1461
1462 def test_show_default_is_text(self, stashed_repo: pathlib.Path) -> None:
1463 r = runner.invoke(cli, ["stash", "show"], env=_env(stashed_repo), catch_exceptions=False)
1464 try:
1465 json.loads(r.output)
1466 assert False, "default output should be text"
1467 except (json.JSONDecodeError, ValueError):
1468 pass
1469
1470 def test_show_empty_exits_1(self, repo: pathlib.Path) -> None:
1471 r = runner.invoke(cli, ["stash", "show"], env=_env(repo))
1472 assert r.exit_code == 1
1473
1474 def test_show_out_of_range_exits_1(self, stashed_repo: pathlib.Path) -> None:
1475 r = runner.invoke(cli, ["stash", "show", "99"], env=_env(stashed_repo))
1476 assert r.exit_code == 1
1477
1478 def test_show_non_integer_exits_1(self, stashed_repo: pathlib.Path) -> None:
1479 r = runner.invoke(cli, ["stash", "show", "xyz"], env=_env(stashed_repo))
1480 assert r.exit_code == 1
1481
1482 def test_show_empty_json_error_schema(self, repo: pathlib.Path) -> None:
1483 r = runner.invoke(cli, ["stash", "show", "--json"], env=_env(repo))
1484 assert r.exit_code != 0
1485 json_lines = [l for l in r.output.splitlines() if l.strip().startswith("{")]
1486 assert json_lines
1487 assert "error" in json.loads(json_lines[0])
1488
1489 def test_show_description_in_help(self) -> None:
1490 r = runner.invoke(cli, ["stash", "show", "--help"], env={})
1491 assert r.exit_code == 0
1492 assert "Index rules" in r.output or "stash@{0}" in r.output
1493
1494 def test_show_outside_repo_exits_2(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
1495 monkeypatch.chdir(tmp_path)
1496 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
1497 r = runner.invoke(cli, ["stash", "show"])
1498 assert r.exit_code == 2
1499
1500 def test_show_files_count_zero_for_empty_manifest(self, repo: pathlib.Path) -> None:
1501 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
1502 stash_path = repo / ".muse/stash.json"
1503 raw = json.loads(stash_path.read_text())
1504 raw[0]["delta"] = {}
1505 stash_path.write_text(json.dumps(raw))
1506 r = runner.invoke(cli, ["stash", "show", "--json"], env=_env(repo), catch_exceptions=False)
1507 d = json.loads(r.output)
1508 assert d["files_count"] == 0
1509 assert d["files"] == []
1510
1511
1512 # ---------------------------------------------------------------------------
1513 # Security — muse stash show
1514 # ---------------------------------------------------------------------------
1515
1516 class TestStashShowSecurity:
1517 """Security tests for ``muse stash show``."""
1518
1519 def test_ansi_in_branch_sanitized(self, repo: pathlib.Path) -> None:
1520 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
1521 stash_path = repo / ".muse/stash.json"
1522 raw = json.loads(stash_path.read_text())
1523 raw[0]["branch"] = "\x1b[31mmain\x1b[0m"
1524 stash_path.write_text(json.dumps(raw))
1525 r = runner.invoke(cli, ["stash", "show"], env=_env(repo), catch_exceptions=False)
1526 assert "\x1b[31m" not in r.output
1527
1528 def test_ansi_in_message_sanitized(self, repo: pathlib.Path) -> None:
1529 runner.invoke(cli, ["stash", "-m", "\x1b[33mwip\x1b[0m"], env=_env(repo), catch_exceptions=False)
1530 r = runner.invoke(cli, ["stash", "show"], env=_env(repo), catch_exceptions=False)
1531 assert "\x1b[33m" not in r.output
1532
1533 def test_ansi_in_file_path_sanitized(self, repo: pathlib.Path) -> None:
1534 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
1535 stash_path = repo / ".muse/stash.json"
1536 raw = json.loads(stash_path.read_text())
1537 raw[0]["delta"]["\x1b[31mevil.py\x1b[0m"] = "a" * 64
1538 stash_path.write_text(json.dumps(raw))
1539 r = runner.invoke(cli, ["stash", "show"], env=_env(repo), catch_exceptions=False)
1540 assert "\x1b[31m" not in r.output
1541
1542 def test_invalid_format_exits_1(self, stashed_repo: pathlib.Path) -> None:
1543 r = runner.invoke(cli, ["stash", "show", "--format", "csv"], env=_env(stashed_repo))
1544 assert r.exit_code == 1
1545 assert "csv" in (r.stderr or r.output).lower()
1546
1547 def test_large_index_rejected(self, stashed_repo: pathlib.Path) -> None:
1548 r = runner.invoke(cli, ["stash", "show", "999999999"], env=_env(stashed_repo))
1549 assert r.exit_code == 1
1550
1551 def test_negative_index_no_crash(self, stashed_repo: pathlib.Path) -> None:
1552 r = runner.invoke(cli, ["stash", "show", "-1"], env=_env(stashed_repo))
1553 assert "Traceback" not in r.output
1554
1555 def test_read_only_no_side_effects(self, stashed_repo: pathlib.Path) -> None:
1556 stash_path = stashed_repo / ".muse/stash.json"
1557 before_stash = stash_path.read_text()
1558 before_files = set(p.name for p in stashed_repo.iterdir())
1559 runner.invoke(cli, ["stash", "show"], env=_env(stashed_repo), catch_exceptions=False)
1560 assert stash_path.read_text() == before_stash
1561 assert set(p.name for p in stashed_repo.iterdir()) == before_files
1562
1563 def test_path_traversal_in_manifest_no_crash(self, repo: pathlib.Path) -> None:
1564 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
1565 stash_path = repo / ".muse/stash.json"
1566 raw = json.loads(stash_path.read_text())
1567 raw[0]["delta"]["../etc/passwd"] = "b" * 64
1568 stash_path.write_text(json.dumps(raw))
1569 r = runner.invoke(cli, ["stash", "show"], env=_env(repo), catch_exceptions=False)
1570 assert "Traceback" not in r.output
1571
1572
1573 # ---------------------------------------------------------------------------
1574 # Stress — muse stash show
1575 # ---------------------------------------------------------------------------
1576
1577 class TestStashShowStress:
1578 """Stress tests for ``muse stash show``."""
1579
1580 @pytest.mark.slow
1581 def test_show_large_manifest(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
1582 """show on an entry with 200 files must complete in < 1 s."""
1583 monkeypatch.chdir(tmp_path)
1584 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
1585 env = _env(tmp_path)
1586 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
1587 (tmp_path / "base.py").write_text("base\n")
1588 runner.invoke(cli, ["commit", "-m", "base"], env=env, catch_exceptions=False)
1589 for i in range(200):
1590 (tmp_path / f"f{i:03d}.py").write_text(f"x={i}\n")
1591 runner.invoke(cli, ["stash"], env=env, catch_exceptions=False)
1592
1593 start = time.perf_counter()
1594 r = runner.invoke(cli, ["stash", "show", "--json"], env=env, catch_exceptions=False)
1595 elapsed = time.perf_counter() - start
1596 assert r.exit_code == 0, r.output
1597 d = json.loads(r.output)
1598 assert d["files_count"] >= 200
1599 assert elapsed < 1.0, f"show took {elapsed:.2f}s"
1600
1601 @pytest.mark.slow
1602 def test_show_all_indices_on_50_entry_stack(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
1603 monkeypatch.chdir(tmp_path)
1604 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
1605 env = _env(tmp_path)
1606 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
1607 (tmp_path / "base.py").write_text("base\n")
1608 runner.invoke(cli, ["commit", "-m", "base"], env=env, catch_exceptions=False)
1609 for i in range(50):
1610 (tmp_path / f"f{i}.py").write_text(f"{i}\n")
1611 runner.invoke(cli, ["stash"], env=env, catch_exceptions=False)
1612
1613 failures: list[str] = []
1614 for i in range(50):
1615 r = runner.invoke(cli, ["stash", "show", str(i), "--json"], env=env)
1616 if r.exit_code != 0:
1617 failures.append(f"show {i}: {r.output.strip()[:60]}")
1618 assert not failures
1619
1620 @pytest.mark.slow
1621 def test_show_repeated_deterministic(self, stashed_repo: pathlib.Path) -> None:
1622 """Calling show 50 times must return identical output every time."""
1623 outputs: list[str] = []
1624 for _ in range(50):
1625 r = runner.invoke(cli, ["stash", "show", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1626 assert r.exit_code == 0
1627 outputs.append(r.output.strip())
1628 assert len(set(outputs)) == 1, "show output must be deterministic"
1629
1630
1631 # ---------------------------------------------------------------------------
1632 # Extended — muse stash list
1633 # ---------------------------------------------------------------------------
1634
1635 class TestStashListExtended:
1636 def test_list_j_alias(self, stashed_repo: pathlib.Path) -> None:
1637 r = runner.invoke(cli, ["stash", "list", "-j"], env=_env(stashed_repo), catch_exceptions=False)
1638 assert r.exit_code == 0
1639 assert isinstance(json.loads(r.output), list)
1640
1641 def test_list_empty_exits_0(self, repo: pathlib.Path) -> None:
1642 assert runner.invoke(cli, ["stash", "list"], env=_env(repo)).exit_code == 0
1643
1644 def test_list_empty_json_is_empty_array(self, repo: pathlib.Path) -> None:
1645 r = runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False)
1646 assert json.loads(r.output) == []
1647
1648 def test_list_empty_text_message(self, repo: pathlib.Path) -> None:
1649 r = runner.invoke(cli, ["stash", "list"], env=_env(repo), catch_exceptions=False)
1650 assert "No stash" in r.output
1651
1652 def test_list_schema_complete(self, stashed_repo: pathlib.Path) -> None:
1653 _REQUIRED = {"index", "snapshot_id", "branch", "stashed_at", "message", "files_count"}
1654 r = runner.invoke(cli, ["stash", "list", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1655 entries = json.loads(r.output)
1656 assert len(entries) == 1
1657 assert _REQUIRED <= entries[0].keys()
1658
1659 def test_list_index_zero_based(self, repo: pathlib.Path) -> None:
1660 for i in range(3):
1661 (repo / f"f{i}.py").write_text(f"{i}\n")
1662 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
1663 r = runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False)
1664 assert [e["index"] for e in json.loads(r.output)] == [0, 1, 2]
1665
1666 def test_list_lifo_order(self, repo: pathlib.Path) -> None:
1667 (repo / "b.py").write_text("first\n")
1668 runner.invoke(cli, ["stash", "-m", "first"], env=_env(repo), catch_exceptions=False)
1669 (repo / "b.py").write_text("second\n")
1670 runner.invoke(cli, ["stash", "-m", "second"], env=_env(repo), catch_exceptions=False)
1671 r = runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False)
1672 entries = json.loads(r.output)
1673 assert entries[0]["message"] == "second"
1674 assert entries[1]["message"] == "first"
1675
1676 def test_list_files_count_accurate(self, repo: pathlib.Path) -> None:
1677 for name in ["a.py", "b.py", "c.py"]:
1678 (repo / name).write_text("x\n")
1679 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
1680 r = runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False)
1681 assert json.loads(r.output)[0]["files_count"] >= 3
1682
1683 def test_list_message_null_when_none(self, stashed_repo: pathlib.Path) -> None:
1684 r = runner.invoke(cli, ["stash", "list", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1685 assert json.loads(r.output)[0]["message"] is None
1686
1687 def test_list_message_when_annotated(self, repo: pathlib.Path) -> None:
1688 runner.invoke(cli, ["stash", "-m", "todo auth"], env=_env(repo), catch_exceptions=False)
1689 r = runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False)
1690 assert json.loads(r.output)[0]["message"] == "todo auth"
1691
1692 def test_list_snapshot_id_sha256(self, stashed_repo: pathlib.Path) -> None:
1693 r = runner.invoke(cli, ["stash", "list", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1694 snap = json.loads(r.output)[0]["snapshot_id"]
1695 assert isinstance(snap, str) and len(snap) == 64
1696
1697 def test_list_stashed_at_iso8601(self, stashed_repo: pathlib.Path) -> None:
1698 import datetime as _dt
1699 r = runner.invoke(cli, ["stash", "list", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1700 ts = _dt.datetime.fromisoformat(json.loads(r.output)[0]["stashed_at"])
1701 assert ts.tzinfo is not None
1702
1703 def test_list_branch_field(self, stashed_repo: pathlib.Path) -> None:
1704 r = runner.invoke(cli, ["stash", "list", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1705 assert json.loads(r.output)[0]["branch"] == "main"
1706
1707 def test_list_text_stash_at_notation(self, stashed_repo: pathlib.Path) -> None:
1708 r = runner.invoke(cli, ["stash", "list"], env=_env(stashed_repo), catch_exceptions=False)
1709 assert "stash@{0}" in r.output
1710
1711 def test_list_text_shows_branch(self, stashed_repo: pathlib.Path) -> None:
1712 r = runner.invoke(cli, ["stash", "list"], env=_env(stashed_repo), catch_exceptions=False)
1713 assert "main" in r.output
1714
1715 def test_list_text_shows_annotation(self, repo: pathlib.Path) -> None:
1716 runner.invoke(cli, ["stash", "-m", "in-progress"], env=_env(repo), catch_exceptions=False)
1717 r = runner.invoke(cli, ["stash", "list"], env=_env(repo), catch_exceptions=False)
1718 assert "in-progress" in r.output
1719
1720 def test_list_default_is_text(self, stashed_repo: pathlib.Path) -> None:
1721 r = runner.invoke(cli, ["stash", "list"], env=_env(stashed_repo), catch_exceptions=False)
1722 try:
1723 json.loads(r.output)
1724 assert False, "default should be text"
1725 except (json.JSONDecodeError, ValueError):
1726 pass
1727
1728 def test_list_does_not_modify_stash_json(self, stashed_repo: pathlib.Path) -> None:
1729 stash_path = stashed_repo / ".muse/stash.json"
1730 mtime_before = stash_path.stat().st_mtime
1731 runner.invoke(cli, ["stash", "list"], env=_env(stashed_repo), catch_exceptions=False)
1732 assert stash_path.stat().st_mtime == mtime_before
1733
1734 def test_list_count_matches_pushes(self, repo: pathlib.Path) -> None:
1735 for i in range(5):
1736 (repo / f"f{i}.py").write_text(f"{i}\n")
1737 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
1738 r = runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False)
1739 assert len(json.loads(r.output)) == 5
1740
1741 def test_list_description_in_help(self) -> None:
1742 r = runner.invoke(cli, ["stash", "list", "--help"], env={})
1743 assert r.exit_code == 0
1744 assert "jq" in r.output or "Agent quickstart" in r.output
1745
1746 def test_list_outside_repo_exits_2(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
1747 monkeypatch.chdir(tmp_path)
1748 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
1749 r = runner.invoke(cli, ["stash", "list"])
1750 assert r.exit_code == 2
1751
1752 def test_list_indices_contiguous(self, repo: pathlib.Path) -> None:
1753 for i in range(4):
1754 (repo / f"f{i}.py").write_text(f"{i}\n")
1755 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
1756 r = runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False)
1757 indices = [e["index"] for e in json.loads(r.output)]
1758 assert indices == list(range(len(indices)))
1759
1760 def test_list_after_pop_decrements(self, repo: pathlib.Path) -> None:
1761 for i in range(3):
1762 (repo / f"f{i}.py").write_text(f"{i}\n")
1763 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
1764 runner.invoke(cli, ["stash", "pop"], env=_env(repo), catch_exceptions=False)
1765 r = runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False)
1766 assert len(json.loads(r.output)) == 2
1767
1768 def test_list_after_drop_decrements(self, repo: pathlib.Path) -> None:
1769 for i in range(3):
1770 (repo / f"f{i}.py").write_text(f"{i}\n")
1771 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
1772 runner.invoke(cli, ["stash", "drop", "1"], env=_env(repo), catch_exceptions=False)
1773 r = runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False)
1774 assert len(json.loads(r.output)) == 2
1775
1776 def test_list_after_apply_unchanged(self, repo: pathlib.Path) -> None:
1777 for i in range(3):
1778 (repo / f"f{i}.py").write_text(f"{i}\n")
1779 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
1780 runner.invoke(cli, ["stash", "apply"], env=_env(repo), catch_exceptions=False)
1781 r = runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False)
1782 assert len(json.loads(r.output)) == 3
1783
1784
1785 # ---------------------------------------------------------------------------
1786 # Security — muse stash list
1787 # ---------------------------------------------------------------------------
1788
1789 class TestStashListSecurity:
1790 def test_ansi_in_branch_sanitized(self, repo: pathlib.Path) -> None:
1791 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
1792 stash_path = repo / ".muse/stash.json"
1793 raw = json.loads(stash_path.read_text())
1794 raw[0]["branch"] = "\x1b[31mmain\x1b[0m"
1795 stash_path.write_text(json.dumps(raw))
1796 r = runner.invoke(cli, ["stash", "list"], env=_env(repo), catch_exceptions=False)
1797 assert "\x1b[31m" not in r.output
1798
1799 def test_ansi_in_message_sanitized(self, repo: pathlib.Path) -> None:
1800 runner.invoke(cli, ["stash", "-m", "\x1b[33mwip\x1b[0m"], env=_env(repo), catch_exceptions=False)
1801 r = runner.invoke(cli, ["stash", "list"], env=_env(repo), catch_exceptions=False)
1802 assert "\x1b[33m" not in r.output
1803
1804 def test_ansi_in_timestamp_sanitized(self, repo: pathlib.Path) -> None:
1805 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
1806 stash_path = repo / ".muse/stash.json"
1807 raw = json.loads(stash_path.read_text())
1808 raw[0]["stashed_at"] = "\x1b[32m2026-01-01T00:00:00+00:00\x1b[0m"
1809 stash_path.write_text(json.dumps(raw))
1810 r = runner.invoke(cli, ["stash", "list"], env=_env(repo), catch_exceptions=False)
1811 assert "\x1b[32m" not in r.output
1812
1813 def test_invalid_format_exits_1(self, stashed_repo: pathlib.Path) -> None:
1814 r = runner.invoke(cli, ["stash", "list", "--format", "yaml"], env=_env(stashed_repo))
1815 assert r.exit_code == 1
1816 assert "yaml" in (r.stderr or r.output).lower()
1817
1818 def test_read_only_no_side_effects(self, stashed_repo: pathlib.Path) -> None:
1819 stash_path = stashed_repo / ".muse/stash.json"
1820 before = stash_path.read_text()
1821 runner.invoke(cli, ["stash", "list"], env=_env(stashed_repo), catch_exceptions=False)
1822 assert stash_path.read_text() == before
1823
1824 def test_malformed_stash_json_empty_list(self, repo: pathlib.Path) -> None:
1825 (repo / ".muse/stash.json").write_text("{not valid json")
1826 r = runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False)
1827 assert r.exit_code == 0
1828 assert json.loads(r.output) == []
1829
1830 def test_oversized_stash_json_empty_list(self, repo: pathlib.Path) -> None:
1831 (repo / ".muse/stash.json").write_bytes(b"x" * (65 * 1024 * 1024))
1832 r = runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False)
1833 assert r.exit_code == 0
1834 assert json.loads(r.output) == []
1835
1836 @pytest.mark.skipif(os.name == "nt", reason="symlinks require elevated privs on Windows")
1837 def test_symlink_stash_json_empty_list(self, repo: pathlib.Path) -> None:
1838 target = repo / "sensitive.txt"
1839 target.write_text("secret")
1840 (repo / ".muse/stash.json").symlink_to(target)
1841 r = runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False)
1842 assert r.exit_code == 0
1843 assert json.loads(r.output) == []
1844
1845
1846 # ---------------------------------------------------------------------------
1847 # Stress — muse stash list
1848 # ---------------------------------------------------------------------------
1849
1850 class TestStashListStress:
1851 @pytest.mark.slow
1852 def test_list_performance_50_entries(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
1853 monkeypatch.chdir(tmp_path)
1854 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
1855 env = _env(tmp_path)
1856 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
1857 (tmp_path / "base.py").write_text("base\n")
1858 runner.invoke(cli, ["commit", "-m", "base"], env=env, catch_exceptions=False)
1859 for i in range(50):
1860 (tmp_path / f"f{i}.py").write_text(f"{i}\n")
1861 runner.invoke(cli, ["stash"], env=env, catch_exceptions=False)
1862
1863 start = time.perf_counter()
1864 r = runner.invoke(cli, ["stash", "list", "--json"], env=env, catch_exceptions=False)
1865 elapsed = time.perf_counter() - start
1866 assert r.exit_code == 0
1867 assert len(json.loads(r.output)) == 50
1868 assert elapsed < 1.0, f"list took {elapsed:.2f}s"
1869
1870 @pytest.mark.slow
1871 def test_list_repeated_deterministic(self, stashed_repo: pathlib.Path) -> None:
1872 outputs = [
1873 runner.invoke(cli, ["stash", "list", "--json"], env=_env(stashed_repo), catch_exceptions=False).output.strip()
1874 for _ in range(50)
1875 ]
1876 assert len(set(outputs)) == 1
1877
1878 @pytest.mark.slow
1879 def test_list_push_pop_count_accurate(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
1880 monkeypatch.chdir(tmp_path)
1881 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
1882 env = _env(tmp_path)
1883 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
1884 (tmp_path / "base.py").write_text("base\n")
1885 runner.invoke(cli, ["commit", "-m", "base"], env=env, catch_exceptions=False)
1886 for i in range(20):
1887 (tmp_path / f"f{i}.py").write_text(f"{i}\n")
1888 runner.invoke(cli, ["stash"], env=env, catch_exceptions=False)
1889 for _ in range(7):
1890 runner.invoke(cli, ["stash", "pop"], env=env, catch_exceptions=False)
1891 r = runner.invoke(cli, ["stash", "list", "--json"], env=env, catch_exceptions=False)
1892 assert len(json.loads(r.output)) == 13
1893
1894
1895 # ---------------------------------------------------------------------------
1896 # Extended — muse stash drop
1897 # ---------------------------------------------------------------------------
1898
1899 class TestStashDropExtended:
1900 def test_drop_j_alias(self, stashed_repo: pathlib.Path) -> None:
1901 r = runner.invoke(cli, ["stash", "drop", "-j"], env=_env(stashed_repo), catch_exceptions=False)
1902 assert r.exit_code == 0
1903 assert json.loads(r.output)["status"] == "dropped"
1904
1905 def test_drop_schema_complete(self, stashed_repo: pathlib.Path) -> None:
1906 _REQUIRED = {"status", "index", "snapshot_id", "branch", "stashed_at", "message", "stash_size"}
1907 r = runner.invoke(cli, ["stash", "drop", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1908 assert _REQUIRED <= json.loads(r.output).keys()
1909
1910 def test_drop_stashed_at_in_json(self, stashed_repo: pathlib.Path) -> None:
1911 import datetime as _dt
1912 r = runner.invoke(cli, ["stash", "drop", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1913 ts = _dt.datetime.fromisoformat(json.loads(r.output)["stashed_at"])
1914 assert ts.tzinfo is not None
1915
1916 def test_drop_message_null_when_none(self, stashed_repo: pathlib.Path) -> None:
1917 r = runner.invoke(cli, ["stash", "drop", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1918 assert json.loads(r.output)["message"] is None
1919
1920 def test_drop_message_when_annotated(self, repo: pathlib.Path) -> None:
1921 runner.invoke(cli, ["stash", "-m", "wip notes"], env=_env(repo), catch_exceptions=False)
1922 r = runner.invoke(cli, ["stash", "drop", "--json"], env=_env(repo), catch_exceptions=False)
1923 assert json.loads(r.output)["message"] == "wip notes"
1924
1925 def test_drop_stash_size_decrements(self, repo: pathlib.Path) -> None:
1926 for i in range(3):
1927 (repo / f"f{i}.py").write_text(f"{i}\n")
1928 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
1929 r = runner.invoke(cli, ["stash", "drop", "--json"], env=_env(repo), catch_exceptions=False)
1930 assert json.loads(r.output)["stash_size"] == 2
1931
1932 def test_drop_removes_entry_from_disk(self, stashed_repo: pathlib.Path) -> None:
1933 before = json.loads((stashed_repo / ".muse/stash.json").read_text())
1934 snap_id = before[0]["snapshot_id"]
1935 runner.invoke(cli, ["stash", "drop"], env=_env(stashed_repo), catch_exceptions=False)
1936 stash_path = stashed_repo / ".muse/stash.json"
1937 if stash_path.exists():
1938 after = json.loads(stash_path.read_text())
1939 assert all(e["snapshot_id"] != snap_id for e in after)
1940
1941 def test_drop_by_index(self, repo: pathlib.Path) -> None:
1942 for i in range(3):
1943 (repo / f"f{i}.py").write_text(f"{i}\n")
1944 runner.invoke(cli, ["stash", "-m", f"e{i}"], env=_env(repo), catch_exceptions=False)
1945 r = runner.invoke(cli, ["stash", "drop", "1", "--json"], env=_env(repo), catch_exceptions=False)
1946 assert json.loads(r.output)["index"] == 1
1947 entries = json.loads(runner.invoke(cli, ["stash", "list", "--json"], env=_env(repo), catch_exceptions=False).output)
1948 assert len(entries) == 2
1949 assert entries[0]["message"] == "e2"
1950 assert entries[1]["message"] == "e0"
1951
1952 def test_drop_last_entry_leaves_empty(self, stashed_repo: pathlib.Path) -> None:
1953 runner.invoke(cli, ["stash", "drop"], env=_env(stashed_repo), catch_exceptions=False)
1954 r = runner.invoke(cli, ["stash", "list", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1955 assert json.loads(r.output) == []
1956
1957 def test_drop_snapshot_id_sha256(self, stashed_repo: pathlib.Path) -> None:
1958 r = runner.invoke(cli, ["stash", "drop", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1959 snap = json.loads(r.output)["snapshot_id"]
1960 assert isinstance(snap, str) and len(snap) == 64
1961
1962 def test_drop_branch_field(self, stashed_repo: pathlib.Path) -> None:
1963 r = runner.invoke(cli, ["stash", "drop", "--json"], env=_env(stashed_repo), catch_exceptions=False)
1964 assert json.loads(r.output)["branch"] == "main"
1965
1966 def test_drop_text_shows_index(self, stashed_repo: pathlib.Path) -> None:
1967 r = runner.invoke(cli, ["stash", "drop"], env=_env(stashed_repo), catch_exceptions=False)
1968 assert "stash@{0}" in r.output
1969
1970 def test_drop_text_shows_branch(self, stashed_repo: pathlib.Path) -> None:
1971 r = runner.invoke(cli, ["stash", "drop"], env=_env(stashed_repo), catch_exceptions=False)
1972 assert "main" in r.output
1973
1974 def test_drop_text_shows_annotation(self, repo: pathlib.Path) -> None:
1975 runner.invoke(cli, ["stash", "-m", "cleanup"], env=_env(repo), catch_exceptions=False)
1976 r = runner.invoke(cli, ["stash", "drop"], env=_env(repo), catch_exceptions=False)
1977 assert "cleanup" in r.output
1978
1979 def test_drop_default_is_text(self, stashed_repo: pathlib.Path) -> None:
1980 r = runner.invoke(cli, ["stash", "drop"], env=_env(stashed_repo), catch_exceptions=False)
1981 try:
1982 json.loads(r.output)
1983 assert False, "default should be text"
1984 except (json.JSONDecodeError, ValueError):
1985 pass
1986
1987 def test_drop_does_not_restore_workdir(self, stashed_repo: pathlib.Path) -> None:
1988 """drop must not restore stashed files to the working tree."""
1989 assert not (stashed_repo / "b.py").exists()
1990 runner.invoke(cli, ["stash", "drop"], env=_env(stashed_repo), catch_exceptions=False)
1991 assert not (stashed_repo / "b.py").exists()
1992
1993 def test_drop_empty_exits_1(self, repo: pathlib.Path) -> None:
1994 r = runner.invoke(cli, ["stash", "drop"], env=_env(repo))
1995 assert r.exit_code == 1
1996
1997 def test_drop_out_of_range_exits_1(self, stashed_repo: pathlib.Path) -> None:
1998 r = runner.invoke(cli, ["stash", "drop", "99"], env=_env(stashed_repo))
1999 assert r.exit_code == 1
2000
2001 def test_drop_non_integer_exits_1(self, stashed_repo: pathlib.Path) -> None:
2002 r = runner.invoke(cli, ["stash", "drop", "abc"], env=_env(stashed_repo))
2003 assert r.exit_code == 1
2004
2005 def test_drop_empty_json_error_schema(self, repo: pathlib.Path) -> None:
2006 r = runner.invoke(cli, ["stash", "drop", "--json"], env=_env(repo))
2007 assert r.exit_code != 0
2008 json_lines = [l for l in r.output.splitlines() if l.strip().startswith("{")]
2009 assert json_lines
2010 d = json.loads(json_lines[0])
2011 assert "error" in d
2012 assert d["stash_size"] == 0
2013
2014 def test_drop_description_in_help(self) -> None:
2015 r = runner.invoke(cli, ["stash", "drop", "--help"], env={})
2016 assert r.exit_code == 0
2017 assert "Index rules" in r.output or "stash@{0}" in r.output
2018
2019 def test_drop_outside_repo_exits_2(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
2020 monkeypatch.chdir(tmp_path)
2021 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
2022 r = runner.invoke(cli, ["stash", "drop"])
2023 assert r.exit_code == 2
2024
2025 def test_drop_index_zero_explicit(self, stashed_repo: pathlib.Path) -> None:
2026 r = runner.invoke(cli, ["stash", "drop", "0", "--json"], env=_env(stashed_repo), catch_exceptions=False)
2027 assert json.loads(r.output)["index"] == 0
2028
2029 def test_drop_stash_size_after_zero_when_last(self, stashed_repo: pathlib.Path) -> None:
2030 r = runner.invoke(cli, ["stash", "drop", "--json"], env=_env(stashed_repo), catch_exceptions=False)
2031 assert json.loads(r.output)["stash_size"] == 0
2032
2033
2034 # ---------------------------------------------------------------------------
2035 # Security — muse stash drop
2036 # ---------------------------------------------------------------------------
2037
2038 class TestStashDropSecurity:
2039 def test_ansi_in_branch_sanitized(self, repo: pathlib.Path) -> None:
2040 runner.invoke(cli, ["stash"], env=_env(repo), catch_exceptions=False)
2041 stash_path = repo / ".muse/stash.json"
2042 raw = json.loads(stash_path.read_text())
2043 raw[0]["branch"] = "\x1b[31mmain\x1b[0m"
2044 stash_path.write_text(json.dumps(raw))
2045 r = runner.invoke(cli, ["stash", "drop"], env=_env(repo), catch_exceptions=False)
2046 assert "\x1b[31m" not in r.output
2047
2048 def test_ansi_in_message_sanitized(self, repo: pathlib.Path) -> None:
2049 runner.invoke(cli, ["stash", "-m", "\x1b[31mwip\x1b[0m"], env=_env(repo), catch_exceptions=False)
2050 r = runner.invoke(cli, ["stash", "drop"], env=_env(repo), catch_exceptions=False)
2051 assert "\x1b[31m" not in r.output
2052
2053 def test_invalid_format_exits_1(self, stashed_repo: pathlib.Path) -> None:
2054 r = runner.invoke(cli, ["stash", "drop", "--format", "xml"], env=_env(stashed_repo))
2055 assert r.exit_code == 1
2056 assert "xml" in (r.stderr or r.output).lower()
2057
2058 def test_large_index_rejected(self, stashed_repo: pathlib.Path) -> None:
2059 r = runner.invoke(cli, ["stash", "drop", "999999999"], env=_env(stashed_repo))
2060 assert r.exit_code == 1
2061
2062 def test_negative_index_no_crash(self, stashed_repo: pathlib.Path) -> None:
2063 r = runner.invoke(cli, ["stash", "drop", "-1"], env=_env(stashed_repo))
2064 assert "Traceback" not in r.output
2065
2066 def test_error_to_stderr(self, repo: pathlib.Path) -> None:
2067 r = runner.invoke(cli, ["stash", "drop"], env=_env(repo))
2068 assert r.exit_code != 0
2069 assert "no stash" in (r.stderr or "").lower()
2070
2071 def test_drop_does_not_leave_partial_on_crash(self, stashed_repo: pathlib.Path) -> None:
2072 """_save_stash uses atomic rename — stash.json is never left partial."""
2073 # Verify stash.json exists and is valid JSON before and after drop
2074 before = json.loads((stashed_repo / ".muse/stash.json").read_text())
2075 runner.invoke(cli, ["stash", "drop"], env=_env(stashed_repo), catch_exceptions=False)
2076 stash_path = stashed_repo / ".muse/stash.json"
2077 if stash_path.exists():
2078 after = json.loads(stash_path.read_text()) # must be valid JSON
2079 assert len(after) == len(before) - 1
2080
2081
2082 # ---------------------------------------------------------------------------
2083 # Stress — muse stash drop
2084 # ---------------------------------------------------------------------------
2085
2086 class TestStashDropStress:
2087 @pytest.mark.slow
2088 def test_drop_all_sequential(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
2089 """Drop all 20 entries sequentially; stash must be empty after."""
2090 monkeypatch.chdir(tmp_path)
2091 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
2092 env = _env(tmp_path)
2093 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
2094 (tmp_path / "base.py").write_text("base\n")
2095 runner.invoke(cli, ["commit", "-m", "base"], env=env, catch_exceptions=False)
2096 for i in range(20):
2097 (tmp_path / f"f{i}.py").write_text(f"{i}\n")
2098 runner.invoke(cli, ["stash"], env=env, catch_exceptions=False)
2099
2100 failures: list[str] = []
2101 for i in range(20):
2102 r = runner.invoke(cli, ["stash", "drop", "--json"], env=env)
2103 if r.exit_code != 0:
2104 failures.append(f"drop {i}: {r.output.strip()[:60]}")
2105 assert not failures
2106 entries = json.loads(runner.invoke(cli, ["stash", "list", "--json"], env=env, catch_exceptions=False).output)
2107 assert entries == []
2108
2109 @pytest.mark.slow
2110 def test_drop_performance_large_stash(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
2111 """drop on a 50-entry stack must complete in < 2 s."""
2112 monkeypatch.chdir(tmp_path)
2113 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
2114 env = _env(tmp_path)
2115 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
2116 (tmp_path / "base.py").write_text("base\n")
2117 runner.invoke(cli, ["commit", "-m", "base"], env=env, catch_exceptions=False)
2118 for i in range(50):
2119 (tmp_path / f"f{i}.py").write_text(f"{i}\n")
2120 runner.invoke(cli, ["stash"], env=env, catch_exceptions=False)
2121
2122 start = time.perf_counter()
2123 r = runner.invoke(cli, ["stash", "drop", "49", "--json"], env=env, catch_exceptions=False)
2124 elapsed = time.perf_counter() - start
2125 assert r.exit_code == 0, r.output
2126 assert elapsed < 2.0, f"drop took {elapsed:.2f}s"
2127
2128 @pytest.mark.slow
2129 def test_drop_concurrent_isolated_repos(self, tmp_path: pathlib.Path) -> None:
2130 """Concurrent drops in isolated repos must not interfere."""
2131 import subprocess
2132 import sys
2133 errors: list[str] = []
2134 muse_bin = pathlib.Path(sys.executable).parent / "muse"
2135
2136 def do_drop(idx: int) -> None:
2137 repo_dir = tmp_path / f"repo_{idx}"
2138 repo_dir.mkdir()
2139 env = {**os.environ, "MUSE_REPO_ROOT": str(repo_dir)}
2140
2141 def run(*args: str) -> subprocess.CompletedProcess[str]:
2142 return subprocess.run([str(muse_bin), *args], capture_output=True, text=True, env=env, cwd=str(repo_dir))
2143
2144 run("init")
2145 (repo_dir / "base.py").write_text("base\n")
2146 run("commit", "-m", "base")
2147 (repo_dir / "work.py").write_text(f"work {idx}\n")
2148 run("stash")
2149 r = run("stash", "drop", "--json")
2150 if r.returncode != 0:
2151 errors.append(f"repo {idx} drop failed: {(r.stdout + r.stderr).strip()[:80]}")
2152 return
2153 try:
2154 d = json.loads(r.stdout)
2155 except json.JSONDecodeError:
2156 errors.append(f"repo {idx} non-JSON: {r.stdout.strip()[:60]}")
2157 return
2158 if d.get("status") != "dropped":
2159 errors.append(f"repo {idx} wrong status: {d.get('status')}")
2160
2161 threads = [threading.Thread(target=do_drop, args=(i,)) for i in range(10)]
2162 for t in threads:
2163 t.start()
2164 for t in threads:
2165 t.join()
2166
2167 assert not errors, f"Concurrent drop errors: {errors}"
File History 3 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago