gabriel / muse public
test_cmd_stash.py python
225 lines 9.0 KB
95b86799 feat: add --format json to all porcelain commands for agent-first output Gabriel Cardona <gabriel@tellurstori.com> 2d ago
1 """Comprehensive tests for ``muse stash``.
2
3 Covers:
4 - Unit: _load_stash / _save_stash atomic write, size guard
5 - Integration: stash → pop, list, drop
6 - E2E: full CLI via CliRunner
7 - Security: stash.json size limit, atomic writes, sanitized output
8 - Stress: many stash entries, repeated save/load
9 """
10
11 from __future__ import annotations
12
13 import datetime
14 import json
15 import pathlib
16 import uuid
17
18 import pytest
19 from typer.testing import CliRunner
20
21 from muse.cli.app import cli
22
23 runner = CliRunner()
24
25
26 # ---------------------------------------------------------------------------
27 # Helpers
28 # ---------------------------------------------------------------------------
29
30 def _env(root: pathlib.Path) -> dict[str, str]:
31 return {"MUSE_REPO_ROOT": str(root)}
32
33
34 def _init_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
35 muse_dir = tmp_path / ".muse"
36 muse_dir.mkdir()
37 repo_id = str(uuid.uuid4())
38 (muse_dir / "repo.json").write_text(json.dumps({
39 "repo_id": repo_id,
40 "domain": "midi",
41 "default_branch": "main",
42 "created_at": "2025-01-01T00:00:00+00:00",
43 }), encoding="utf-8")
44 (muse_dir / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
45 (muse_dir / "refs" / "heads").mkdir(parents=True)
46 (muse_dir / "snapshots").mkdir()
47 (muse_dir / "commits").mkdir()
48 (muse_dir / "objects").mkdir()
49 return tmp_path, repo_id
50
51
52 def _make_commit(root: pathlib.Path, repo_id: str, message: str = "init") -> str:
53 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
54 from muse.core.snapshot import compute_snapshot_id, compute_commit_id
55
56 ref_file = root / ".muse" / "refs" / "heads" / "main"
57 parent_id = ref_file.read_text().strip() if ref_file.exists() else None
58 manifest: dict[str, str] = {}
59 snap_id = compute_snapshot_id(manifest)
60 committed_at = datetime.datetime.now(datetime.timezone.utc)
61 commit_id = compute_commit_id(
62 parent_ids=[parent_id] if parent_id else [],
63 snapshot_id=snap_id, message=message,
64 committed_at_iso=committed_at.isoformat(),
65 )
66 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
67 write_commit(root, CommitRecord(
68 commit_id=commit_id, repo_id=repo_id, branch="main",
69 snapshot_id=snap_id, message=message, committed_at=committed_at,
70 parent_commit_id=parent_id,
71 ))
72 ref_file.parent.mkdir(parents=True, exist_ok=True)
73 ref_file.write_text(commit_id, encoding="utf-8")
74 return commit_id
75
76
77 # ---------------------------------------------------------------------------
78 # Unit tests
79 # ---------------------------------------------------------------------------
80
81 class TestStashUnit:
82 def test_load_stash_empty(self, tmp_path: pathlib.Path) -> None:
83 root, _ = _init_repo(tmp_path)
84 from muse.cli.commands.stash import _load_stash
85 assert _load_stash(root) == []
86
87 def test_save_and_load_stash_roundtrip(self, tmp_path: pathlib.Path) -> None:
88 root, _ = _init_repo(tmp_path)
89 from muse.cli.commands.stash import _load_stash, _save_stash, StashEntry
90 entry = StashEntry(
91 snapshot_id="a" * 64, manifest={"file.mid": "b" * 64},
92 branch="main", stashed_at="2025-01-01T00:00:00+00:00",
93 )
94 _save_stash(root, [entry])
95 loaded = _load_stash(root)
96 assert len(loaded) == 1
97 assert loaded[0]["snapshot_id"] == "a" * 64
98 assert loaded[0]["branch"] == "main"
99
100 def test_save_stash_is_atomic(self, tmp_path: pathlib.Path) -> None:
101 """After _save_stash, no temp files should remain in .muse/."""
102 root, _ = _init_repo(tmp_path)
103 from muse.cli.commands.stash import _save_stash, StashEntry
104 entry = StashEntry(
105 snapshot_id="c" * 64, manifest={},
106 branch="dev", stashed_at="2025-01-01T00:00:00+00:00",
107 )
108 _save_stash(root, [entry])
109 tmp_files = list((root / ".muse").glob(".stash_tmp_*"))
110 assert tmp_files == []
111 assert (root / ".muse" / "stash.json").exists()
112
113 def test_load_stash_ignores_oversized_file(self, tmp_path: pathlib.Path) -> None:
114 root, _ = _init_repo(tmp_path)
115 stash_path = root / ".muse" / "stash.json"
116 stash_path.write_bytes(b"x" * (65 * 1024 * 1024)) # 65 MiB > 64 MiB limit
117 from muse.cli.commands.stash import _load_stash
118 result = _load_stash(root)
119 assert result == []
120
121
122 # ---------------------------------------------------------------------------
123 # Integration tests
124 # ---------------------------------------------------------------------------
125
126 class TestStashIntegration:
127 def test_stash_with_no_changes_reports_nothing(self, tmp_path: pathlib.Path) -> None:
128 root, repo_id = _init_repo(tmp_path)
129 _make_commit(root, repo_id)
130 result = runner.invoke(cli, ["stash"], env=_env(root), catch_exceptions=False)
131 assert "Nothing to stash" in result.output
132
133 def test_stash_list_empty(self, tmp_path: pathlib.Path) -> None:
134 root, repo_id = _init_repo(tmp_path)
135 _make_commit(root, repo_id)
136 result = runner.invoke(cli, ["stash", "list"], env=_env(root), catch_exceptions=False)
137 assert "No stash entries" in result.output
138
139 def test_stash_pop_empty_fails(self, tmp_path: pathlib.Path) -> None:
140 root, repo_id = _init_repo(tmp_path)
141 _make_commit(root, repo_id)
142 result = runner.invoke(cli, ["stash", "pop"], env=_env(root))
143 assert result.exit_code != 0
144
145 def test_stash_drop_empty_fails(self, tmp_path: pathlib.Path) -> None:
146 root, repo_id = _init_repo(tmp_path)
147 _make_commit(root, repo_id)
148 result = runner.invoke(cli, ["stash", "drop"], env=_env(root))
149 assert result.exit_code != 0
150
151 def test_stash_list_shows_entries(self, tmp_path: pathlib.Path) -> None:
152 root, _ = _init_repo(tmp_path)
153 from muse.cli.commands.stash import _save_stash, StashEntry
154 _save_stash(root, [
155 StashEntry(snapshot_id="a" * 64, manifest={},
156 branch="main", stashed_at="2025-01-01T00:00:00+00:00"),
157 ])
158 result = runner.invoke(cli, ["stash", "list"], env=_env(root), catch_exceptions=False)
159 assert "stash@{0}" in result.output
160 assert "main" in result.output
161
162
163 # ---------------------------------------------------------------------------
164 # Security tests
165 # ---------------------------------------------------------------------------
166
167 class TestStashSecurity:
168 def test_stash_list_sanitizes_branch_name_with_control_chars(
169 self, tmp_path: pathlib.Path
170 ) -> None:
171 root, _ = _init_repo(tmp_path)
172 from muse.cli.commands.stash import _save_stash, StashEntry
173 malicious_branch = "feat/\x1b[31mred\x1b[0m"
174 _save_stash(root, [
175 StashEntry(snapshot_id="a" * 64, manifest={},
176 branch=malicious_branch, stashed_at="2025-01-01T00:00:00+00:00"),
177 ])
178 result = runner.invoke(cli, ["stash", "list"], env=_env(root), catch_exceptions=False)
179 assert result.exit_code == 0
180 assert "\x1b" not in result.output
181
182 def test_stash_pop_sanitizes_branch_name(self, tmp_path: pathlib.Path) -> None:
183 root, repo_id = _init_repo(tmp_path)
184 _make_commit(root, repo_id)
185 from muse.cli.commands.stash import _save_stash, StashEntry
186 _save_stash(root, [
187 StashEntry(snapshot_id="a" * 64, manifest={},
188 branch="feat/\x1b[31mred\x1b[0m",
189 stashed_at="2025-01-01T00:00:00+00:00"),
190 ])
191 result = runner.invoke(cli, ["stash", "pop"], env=_env(root), catch_exceptions=False)
192 assert "\x1b" not in result.output
193
194
195 # ---------------------------------------------------------------------------
196 # Stress tests
197 # ---------------------------------------------------------------------------
198
199 class TestStashStress:
200 def test_many_stash_entries_save_load(self, tmp_path: pathlib.Path) -> None:
201 root, _ = _init_repo(tmp_path)
202 from muse.cli.commands.stash import _save_stash, _load_stash, StashEntry
203 entries = [
204 StashEntry(snapshot_id=f"{'a' * 63}{i % 10}",
205 manifest={"file.mid": "b" * 64}, branch="main",
206 stashed_at=f"2025-01-{i % 28 + 1:02d}T00:00:00+00:00")
207 for i in range(50)
208 ]
209 _save_stash(root, entries)
210 loaded = _load_stash(root)
211 assert len(loaded) == 50
212
213 def test_repeated_save_load_no_corruption(self, tmp_path: pathlib.Path) -> None:
214 root, _ = _init_repo(tmp_path)
215 from muse.cli.commands.stash import _save_stash, _load_stash, StashEntry
216 for i in range(20):
217 entry = StashEntry(snapshot_id=f"{'b' * 63}{i % 10}",
218 manifest={}, branch="main",
219 stashed_at="2025-01-01T00:00:00+00:00")
220 loaded = _load_stash(root)
221 loaded.insert(0, entry)
222 _save_stash(root, loaded)
223
224 final = _load_stash(root)
225 assert len(final) == 20