test_commit_tree.py
python
| 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 |