cgcardona / muse public
test_commit_tree.py python
333 lines 10.2 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Tests for ``muse commit-tree``.
2
3 Tests exercise ``_commit_tree_async`` directly with an in-memory SQLite session
4 so no real Postgres instance is required. The ``muse_cli_db_session`` fixture
5 (from tests/muse_cli/conftest.py) provides the isolated SQLite session.
6
7 All async tests use ``@pytest.mark.anyio``.
8 """
9 from __future__ import annotations
10
11 import pathlib
12 import uuid
13
14 import pytest
15 import typer
16 from sqlalchemy.ext.asyncio import AsyncSession
17 from sqlalchemy.future import select
18
19 from maestro.muse_cli.commands.commit_tree import _commit_tree_async
20 from maestro.muse_cli.errors import ExitCode
21 from maestro.muse_cli.models import MuseCliCommit, MuseCliSnapshot
22 from maestro.muse_cli.snapshot import compute_commit_tree_id
23
24
25 # ---------------------------------------------------------------------------
26 # Helpers
27 # ---------------------------------------------------------------------------
28
29
30 async def _seed_snapshot(session: AsyncSession, files: int = 2) -> str:
31 """Insert a minimal MuseCliSnapshot row and return its snapshot_id."""
32 snapshot_id = "a" * 64 # fixed deterministic value for test simplicity
33 manifest: dict[str, str] = {f"track{i}.mid": "b" * 64 for i in range(files)}
34 snap = MuseCliSnapshot(snapshot_id=snapshot_id, manifest=manifest)
35 session.add(snap)
36 await session.flush()
37 return snapshot_id
38
39
40 # ---------------------------------------------------------------------------
41 # Happy-path tests
42 # ---------------------------------------------------------------------------
43
44
45 @pytest.mark.anyio
46 async def test_commit_tree_creates_commit_row(
47 muse_cli_db_session: AsyncSession,
48 ) -> None:
49 """commit-tree inserts a MuseCliCommit row with correct fields."""
50 snapshot_id = await _seed_snapshot(muse_cli_db_session)
51
52 commit_id = await _commit_tree_async(
53 snapshot_id=snapshot_id,
54 message="raw commit",
55 parent_ids=[],
56 author="Alice",
57 session=muse_cli_db_session,
58 )
59
60 result = await muse_cli_db_session.execute(
61 select(MuseCliCommit).where(MuseCliCommit.commit_id == commit_id)
62 )
63 row = result.scalar_one_or_none()
64 assert row is not None, "commit row must exist after _commit_tree_async"
65 assert row.message == "raw commit"
66 assert row.author == "Alice"
67 assert row.snapshot_id == snapshot_id
68 assert row.parent_commit_id is None
69 assert row.parent2_commit_id is None
70
71
72 @pytest.mark.anyio
73 async def test_commit_tree_returns_64_char_hex(
74 muse_cli_db_session: AsyncSession,
75 ) -> None:
76 """commit_id returned by commit-tree is a valid 64-char hex SHA-256."""
77 snapshot_id = await _seed_snapshot(muse_cli_db_session)
78
79 commit_id = await _commit_tree_async(
80 snapshot_id=snapshot_id,
81 message="hex check",
82 parent_ids=[],
83 author="",
84 session=muse_cli_db_session,
85 )
86
87 assert len(commit_id) == 64
88 assert all(c in "0123456789abcdef" for c in commit_id)
89
90
91 @pytest.mark.anyio
92 async def test_commit_tree_does_not_update_any_ref(
93 tmp_path: pathlib.Path,
94 muse_cli_db_session: AsyncSession,
95 ) -> None:
96 """commit-tree must NOT write to .muse/refs/ or .muse/HEAD."""
97 # Set up a minimal .muse layout so we can verify no ref is written
98 muse_dir = tmp_path / ".muse"
99 refs_dir = muse_dir / "refs" / "heads"
100 refs_dir.mkdir(parents=True)
101 (muse_dir / "HEAD").write_text("refs/heads/main")
102 (refs_dir / "main").write_text("deadbeef" * 8) # fake HEAD SHA
103
104 snapshot_id = await _seed_snapshot(muse_cli_db_session)
105
106 await _commit_tree_async(
107 snapshot_id=snapshot_id,
108 message="should not move ref",
109 parent_ids=[],
110 author="",
111 session=muse_cli_db_session,
112 )
113
114 # Ref must be unchanged
115 head_ref = (refs_dir / "main").read_text()
116 assert head_ref == "deadbeef" * 8, "commit-tree must not update branch ref"
117 # HEAD must be unchanged
118 head_content = (muse_dir / "HEAD").read_text()
119 assert head_content == "refs/heads/main"
120
121
122 @pytest.mark.anyio
123 async def test_commit_tree_single_parent(
124 muse_cli_db_session: AsyncSession,
125 ) -> None:
126 """With one -p flag, parent_commit_id is set and parent2 remains None."""
127 snapshot_id = await _seed_snapshot(muse_cli_db_session)
128 parent_id = "c" * 64
129
130 commit_id = await _commit_tree_async(
131 snapshot_id=snapshot_id,
132 message="has parent",
133 parent_ids=[parent_id],
134 author="",
135 session=muse_cli_db_session,
136 )
137
138 result = await muse_cli_db_session.execute(
139 select(MuseCliCommit).where(MuseCliCommit.commit_id == commit_id)
140 )
141 row = result.scalar_one()
142 assert row.parent_commit_id == parent_id
143 assert row.parent2_commit_id is None
144
145
146 @pytest.mark.anyio
147 async def test_commit_tree_merge_commit_two_parents(
148 muse_cli_db_session: AsyncSession,
149 ) -> None:
150 """With two -p flags, both parent columns are populated (merge commit)."""
151 snapshot_id = await _seed_snapshot(muse_cli_db_session)
152 parent1 = "d" * 64
153 parent2 = "e" * 64
154
155 commit_id = await _commit_tree_async(
156 snapshot_id=snapshot_id,
157 message="merge commit",
158 parent_ids=[parent1, parent2],
159 author="",
160 session=muse_cli_db_session,
161 )
162
163 result = await muse_cli_db_session.execute(
164 select(MuseCliCommit).where(MuseCliCommit.commit_id == commit_id)
165 )
166 row = result.scalar_one()
167 assert row.parent_commit_id == parent1
168 assert row.parent2_commit_id == parent2
169
170
171 @pytest.mark.anyio
172 async def test_commit_tree_idempotent_same_inputs(
173 muse_cli_db_session: AsyncSession,
174 ) -> None:
175 """Calling commit-tree twice with identical inputs returns the same commit_id
176 without inserting a duplicate row."""
177 snapshot_id = await _seed_snapshot(muse_cli_db_session)
178
179 async def _call() -> str:
180 return await _commit_tree_async(
181 snapshot_id=snapshot_id,
182 message="idempotent",
183 parent_ids=[],
184 author="Bob",
185 session=muse_cli_db_session,
186 )
187
188 id1 = await _call()
189 id2 = await _call()
190
191 assert id1 == id2, "same inputs must produce the same commit_id"
192
193 # Only one row must exist
194 result = await muse_cli_db_session.execute(
195 select(MuseCliCommit).where(MuseCliCommit.commit_id == id1)
196 )
197 rows = result.scalars().all()
198 assert len(rows) == 1, "idempotent call must not insert a duplicate row"
199
200
201 @pytest.mark.anyio
202 async def test_commit_tree_deterministic_hash(
203 muse_cli_db_session: AsyncSession,
204 ) -> None:
205 """compute_commit_tree_id is deterministic: same inputs → same digest."""
206 snapshot_id = "f" * 64
207 parent = "0" * 64
208
209 h1 = compute_commit_tree_id(
210 parent_ids=[parent],
211 snapshot_id=snapshot_id,
212 message="determinism",
213 author="Carol",
214 )
215 h2 = compute_commit_tree_id(
216 parent_ids=[parent],
217 snapshot_id=snapshot_id,
218 message="determinism",
219 author="Carol",
220 )
221 assert h1 == h2
222 assert len(h1) == 64
223
224
225 @pytest.mark.anyio
226 async def test_commit_tree_different_messages_different_ids(
227 muse_cli_db_session: AsyncSession,
228 ) -> None:
229 """Different messages produce different commit_ids for the same snapshot."""
230 snapshot_id = await _seed_snapshot(muse_cli_db_session)
231
232 # Use a second unique snapshot for the second call
233 snap2_id = "b" * 64
234 snap2 = MuseCliSnapshot(snapshot_id=snap2_id, manifest={"x.mid": "c" * 64})
235 muse_cli_db_session.add(snap2)
236 await muse_cli_db_session.flush()
237
238 id1 = await _commit_tree_async(
239 snapshot_id=snapshot_id,
240 message="message A",
241 parent_ids=[],
242 author="",
243 session=muse_cli_db_session,
244 )
245 id2 = await _commit_tree_async(
246 snapshot_id=snap2_id,
247 message="message B",
248 parent_ids=[],
249 author="",
250 session=muse_cli_db_session,
251 )
252
253 assert id1 != id2
254
255
256 @pytest.mark.anyio
257 async def test_commit_tree_branch_is_empty_string(
258 muse_cli_db_session: AsyncSession,
259 ) -> None:
260 """commit-tree stores branch as empty string (not associated with any ref)."""
261 snapshot_id = await _seed_snapshot(muse_cli_db_session)
262
263 commit_id = await _commit_tree_async(
264 snapshot_id=snapshot_id,
265 message="branch check",
266 parent_ids=[],
267 author="",
268 session=muse_cli_db_session,
269 )
270
271 result = await muse_cli_db_session.execute(
272 select(MuseCliCommit).where(MuseCliCommit.commit_id == commit_id)
273 )
274 row = result.scalar_one()
275 assert row.branch == "", "commit-tree must not associate with any branch"
276
277
278 # ---------------------------------------------------------------------------
279 # Error cases
280 # ---------------------------------------------------------------------------
281
282
283 @pytest.mark.anyio
284 async def test_commit_tree_unknown_snapshot_exits_1(
285 muse_cli_db_session: AsyncSession,
286 ) -> None:
287 """When snapshot_id is not in the DB, commit-tree exits USER_ERROR."""
288 nonexistent_snapshot = "9" * 64
289
290 with pytest.raises(typer.Exit) as exc_info:
291 await _commit_tree_async(
292 snapshot_id=nonexistent_snapshot,
293 message="ghost snapshot",
294 parent_ids=[],
295 author="",
296 session=muse_cli_db_session,
297 )
298
299 assert exc_info.value.exit_code == ExitCode.USER_ERROR
300
301
302 @pytest.mark.anyio
303 async def test_commit_tree_too_many_parents_exits_1(
304 muse_cli_db_session: AsyncSession,
305 ) -> None:
306 """Supplying more than 2 parent IDs exits USER_ERROR (DB only stores 2)."""
307 snapshot_id = await _seed_snapshot(muse_cli_db_session)
308
309 with pytest.raises(typer.Exit) as exc_info:
310 await _commit_tree_async(
311 snapshot_id=snapshot_id,
312 message="octopus merge",
313 parent_ids=["a" * 64, "b" * 64, "c" * 64],
314 author="",
315 session=muse_cli_db_session,
316 )
317
318 assert exc_info.value.exit_code == ExitCode.USER_ERROR
319
320
321 def test_commit_tree_no_repo_exits_2(tmp_path: pathlib.Path) -> None:
322 """Typer CLI runner: commit-tree outside a repo exits REPO_NOT_FOUND."""
323 from typer.testing import CliRunner
324
325 from maestro.muse_cli.app import cli
326
327 runner = CliRunner()
328 result = runner.invoke(
329 cli,
330 ["commit-tree", "a" * 64, "-m", "no repo"],
331 catch_exceptions=False,
332 )
333 assert result.exit_code == ExitCode.REPO_NOT_FOUND