cgcardona / muse public
test_amend.py python
434 lines 14.1 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Tests for ``muse amend``.
2
3 Exercises ``_amend_async`` directly with an in-memory SQLite session so no
4 real Postgres instance is required. The ``muse_cli_db_session`` fixture
5 (defined in tests/muse_cli/conftest.py) provides the isolated session.
6
7 Test coverage
8 -------------
9 - ``test_muse_amend_updates_last_commit`` — regression: amend replaces HEAD ref
10 - ``test_muse_amend_message_only`` — -m flag updates message
11 - ``test_muse_amend_blocked_during_merge`` — blocked when MERGE_STATE.json exists
12 - Plus: no-commits guard, parent inheritance, --no-edit, --reset-author,
13 outside-repo exit code, empty muse-work/ guard, missing muse-work/ guard.
14 """
15 from __future__ import annotations
16
17 import json
18 import pathlib
19 import uuid
20
21 import pytest
22 import typer
23 from sqlalchemy.ext.asyncio import AsyncSession
24 from sqlalchemy.future import select
25
26 from maestro.muse_cli.commands.amend import _amend_async
27 from maestro.muse_cli.commands.commit import _commit_async
28 from maestro.muse_cli.errors import ExitCode
29 from maestro.muse_cli.models import MuseCliCommit
30
31
32 # ---------------------------------------------------------------------------
33 # Helpers (mirrors commit tests to keep the fixture surface minimal)
34 # ---------------------------------------------------------------------------
35
36
37 def _init_muse_repo(root: pathlib.Path, repo_id: str | None = None) -> str:
38 """Create a minimal .muse/ layout so _amend_async can read repo state."""
39 rid = repo_id or str(uuid.uuid4())
40 muse = root / ".muse"
41 (muse / "refs" / "heads").mkdir(parents=True)
42 (muse / "repo.json").write_text(
43 json.dumps({"repo_id": rid, "schema_version": "1"})
44 )
45 (muse / "HEAD").write_text("refs/heads/main")
46 (muse / "refs" / "heads" / "main").write_text("") # no commits yet
47 return rid
48
49
50 def _populate_workdir(root: pathlib.Path, files: dict[str, bytes] | None = None) -> None:
51 """Create muse-work/ with one or more files."""
52 workdir = root / "muse-work"
53 workdir.mkdir(exist_ok=True)
54 if files is None:
55 files = {"beat.mid": b"MIDI-DATA", "lead.mp3": b"MP3-DATA"}
56 for name, content in files.items():
57 (workdir / name).write_bytes(content)
58
59
60 async def _make_commit(
61 root: pathlib.Path,
62 session: AsyncSession,
63 *,
64 message: str = "initial commit",
65 files: dict[str, bytes] | None = None,
66 ) -> str:
67 """Helper: populate workdir and run _commit_async, return commit_id."""
68 _populate_workdir(root, files=files)
69 return await _commit_async(message=message, root=root, session=session)
70
71
72 # ---------------------------------------------------------------------------
73 # Core regression tests (named per issue acceptance criteria)
74 # ---------------------------------------------------------------------------
75
76
77 @pytest.mark.anyio
78 async def test_muse_amend_updates_last_commit(
79 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
80 ) -> None:
81 """Amend re-snapshots muse-work/ and updates .muse/refs/heads/<branch>."""
82 _init_muse_repo(tmp_path)
83 original_id = await _make_commit(
84 tmp_path, muse_cli_db_session, message="original", files={"a.mid": b"V1"}
85 )
86
87 # Modify the working tree
88 (tmp_path / "muse-work" / "a.mid").write_bytes(b"V2")
89
90 new_id = await _amend_async(
91 message=None,
92 no_edit=True,
93 reset_author=False,
94 root=tmp_path,
95 session=muse_cli_db_session,
96 )
97
98 # HEAD ref must point to the new commit
99 ref_content = (tmp_path / ".muse" / "refs" / "heads" / "main").read_text().strip()
100 assert ref_content == new_id
101 assert new_id != original_id
102
103
104 @pytest.mark.anyio
105 async def test_muse_amend_message_only(
106 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
107 ) -> None:
108 """Amend with -m replaces the commit message."""
109 _init_muse_repo(tmp_path)
110 await _make_commit(tmp_path, muse_cli_db_session, message="old message")
111
112 new_id = await _amend_async(
113 message="new message",
114 no_edit=False,
115 reset_author=False,
116 root=tmp_path,
117 session=muse_cli_db_session,
118 )
119
120 result = await muse_cli_db_session.execute(
121 select(MuseCliCommit).where(MuseCliCommit.commit_id == new_id)
122 )
123 row = result.scalar_one()
124 assert row.message == "new message"
125
126
127 @pytest.mark.anyio
128 async def test_muse_amend_blocked_during_merge(
129 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
130 ) -> None:
131 """Amend is blocked when MERGE_STATE.json exists (merge in progress)."""
132 _init_muse_repo(tmp_path)
133 await _make_commit(tmp_path, muse_cli_db_session)
134
135 # Simulate an in-progress merge
136 merge_state = {
137 "base_commit": "abc",
138 "ours_commit": "def",
139 "theirs_commit": "ghi",
140 "conflict_paths": ["beat.mid"],
141 "other_branch": "feature/x",
142 }
143 (tmp_path / ".muse" / "MERGE_STATE.json").write_text(json.dumps(merge_state))
144
145 with pytest.raises(typer.Exit) as exc_info:
146 await _amend_async(
147 message=None,
148 no_edit=True,
149 reset_author=False,
150 root=tmp_path,
151 session=muse_cli_db_session,
152 )
153 assert exc_info.value.exit_code == ExitCode.USER_ERROR
154
155
156 # ---------------------------------------------------------------------------
157 # Guard: no commits yet
158 # ---------------------------------------------------------------------------
159
160
161 @pytest.mark.anyio
162 async def test_muse_amend_no_commits_exits_1(
163 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
164 ) -> None:
165 """Amend with no prior commits exits USER_ERROR."""
166 _init_muse_repo(tmp_path)
167 _populate_workdir(tmp_path)
168
169 with pytest.raises(typer.Exit) as exc_info:
170 await _amend_async(
171 message="oops",
172 no_edit=False,
173 reset_author=False,
174 root=tmp_path,
175 session=muse_cli_db_session,
176 )
177 assert exc_info.value.exit_code == ExitCode.USER_ERROR
178
179
180 # ---------------------------------------------------------------------------
181 # Parent inheritance — amended commit keeps original's parent
182 # ---------------------------------------------------------------------------
183
184
185 @pytest.mark.anyio
186 async def test_muse_amend_preserves_grandparent(
187 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
188 ) -> None:
189 """The amended commit's parent is the *original commit's parent*, not the original itself."""
190 _init_muse_repo(tmp_path)
191
192 # Commit 1
193 cid1 = await _make_commit(
194 tmp_path, muse_cli_db_session, message="commit 1", files={"a.mid": b"V1"}
195 )
196
197 # Commit 2 (this is HEAD, the one we will amend)
198 (tmp_path / "muse-work" / "a.mid").write_bytes(b"V2")
199 _cid2 = await _commit_async(
200 message="commit 2", root=tmp_path, session=muse_cli_db_session
201 )
202
203 # Amend commit 2
204 (tmp_path / "muse-work" / "a.mid").write_bytes(b"V3")
205 amended_id = await _amend_async(
206 message="commit 2 amended",
207 no_edit=False,
208 reset_author=False,
209 root=tmp_path,
210 session=muse_cli_db_session,
211 )
212
213 result = await muse_cli_db_session.execute(
214 select(MuseCliCommit).where(MuseCliCommit.commit_id == amended_id)
215 )
216 amended_row = result.scalar_one()
217 # The amended commit's parent must be commit 1, not commit 2
218 assert amended_row.parent_commit_id == cid1
219
220
221 @pytest.mark.anyio
222 async def test_muse_amend_first_commit_has_no_parent(
223 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
224 ) -> None:
225 """Amending the very first commit produces a root commit (parent_commit_id is None)."""
226 _init_muse_repo(tmp_path)
227 await _make_commit(tmp_path, muse_cli_db_session, message="root commit")
228
229 # Amend — workdir unchanged, just new message
230 amended_id = await _amend_async(
231 message="root amended",
232 no_edit=False,
233 reset_author=False,
234 root=tmp_path,
235 session=muse_cli_db_session,
236 )
237
238 result = await muse_cli_db_session.execute(
239 select(MuseCliCommit).where(MuseCliCommit.commit_id == amended_id)
240 )
241 row = result.scalar_one()
242 assert row.parent_commit_id is None
243
244
245 # ---------------------------------------------------------------------------
246 # --no-edit keeps original message
247 # ---------------------------------------------------------------------------
248
249
250 @pytest.mark.anyio
251 async def test_muse_amend_no_edit_keeps_original_message(
252 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
253 ) -> None:
254 """--no-edit preserves the original commit message even when -m is also provided."""
255 _init_muse_repo(tmp_path)
256 await _make_commit(tmp_path, muse_cli_db_session, message="keep this message")
257
258 # Modify workdir so we get a new snapshot
259 (tmp_path / "muse-work" / "beat.mid").write_bytes(b"UPDATED")
260
261 # Supply -m but also --no-edit; --no-edit wins
262 amended_id = await _amend_async(
263 message="should be ignored",
264 no_edit=True,
265 reset_author=False,
266 root=tmp_path,
267 session=muse_cli_db_session,
268 )
269
270 result = await muse_cli_db_session.execute(
271 select(MuseCliCommit).where(MuseCliCommit.commit_id == amended_id)
272 )
273 row = result.scalar_one()
274 assert row.message == "keep this message"
275
276
277 @pytest.mark.anyio
278 async def test_muse_amend_no_message_flag_keeps_original(
279 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
280 ) -> None:
281 """When neither -m nor --no-edit is supplied, original message is kept."""
282 _init_muse_repo(tmp_path)
283 await _make_commit(tmp_path, muse_cli_db_session, message="original message")
284
285 (tmp_path / "muse-work" / "beat.mid").write_bytes(b"UPDATED")
286
287 amended_id = await _amend_async(
288 message=None,
289 no_edit=False,
290 reset_author=False,
291 root=tmp_path,
292 session=muse_cli_db_session,
293 )
294
295 result = await muse_cli_db_session.execute(
296 select(MuseCliCommit).where(MuseCliCommit.commit_id == amended_id)
297 )
298 row = result.scalar_one()
299 assert row.message == "original message"
300
301
302 # ---------------------------------------------------------------------------
303 # --reset-author (stub: always produces empty author string)
304 # ---------------------------------------------------------------------------
305
306
307 @pytest.mark.anyio
308 async def test_muse_amend_reset_author_flag_accepted(
309 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
310 ) -> None:
311 """--reset-author is accepted without error (stub implementation)."""
312 _init_muse_repo(tmp_path)
313 await _make_commit(tmp_path, muse_cli_db_session)
314
315 (tmp_path / "muse-work" / "beat.mid").write_bytes(b"UPDATED")
316
317 amended_id = await _amend_async(
318 message=None,
319 no_edit=True,
320 reset_author=True,
321 root=tmp_path,
322 session=muse_cli_db_session,
323 )
324
325 result = await muse_cli_db_session.execute(
326 select(MuseCliCommit).where(MuseCliCommit.commit_id == amended_id)
327 )
328 row = result.scalar_one()
329 # Stub: always empty string until a user-identity system is added
330 assert row.author == ""
331
332
333 # ---------------------------------------------------------------------------
334 # Empty / missing muse-work/ guards
335 # ---------------------------------------------------------------------------
336
337
338 @pytest.mark.anyio
339 async def test_muse_amend_missing_workdir_exits_1(
340 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
341 ) -> None:
342 """When muse-work/ does not exist, amend exits USER_ERROR."""
343 _init_muse_repo(tmp_path)
344 await _make_commit(tmp_path, muse_cli_db_session)
345
346 # Remove muse-work/ entirely
347 import shutil
348 shutil.rmtree(tmp_path / "muse-work")
349
350 with pytest.raises(typer.Exit) as exc_info:
351 await _amend_async(
352 message=None,
353 no_edit=True,
354 reset_author=False,
355 root=tmp_path,
356 session=muse_cli_db_session,
357 )
358 assert exc_info.value.exit_code == ExitCode.USER_ERROR
359
360
361 @pytest.mark.anyio
362 async def test_muse_amend_empty_workdir_exits_1(
363 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
364 ) -> None:
365 """When muse-work/ is empty, amend exits USER_ERROR."""
366 _init_muse_repo(tmp_path)
367 await _make_commit(tmp_path, muse_cli_db_session)
368
369 # Empty the workdir
370 import shutil
371 shutil.rmtree(tmp_path / "muse-work")
372 (tmp_path / "muse-work").mkdir()
373
374 with pytest.raises(typer.Exit) as exc_info:
375 await _amend_async(
376 message=None,
377 no_edit=True,
378 reset_author=False,
379 root=tmp_path,
380 session=muse_cli_db_session,
381 )
382 assert exc_info.value.exit_code == ExitCode.USER_ERROR
383
384
385 # ---------------------------------------------------------------------------
386 # Outside-repo exit code (Typer CLI runner)
387 # ---------------------------------------------------------------------------
388
389
390 @pytest.mark.anyio
391 async def test_muse_amend_outside_repo_exits_2(
392 tmp_path: pathlib.Path,
393 muse_cli_db_session: AsyncSession,
394 ) -> None:
395 """``muse amend`` exits REPO_NOT_FOUND (2) when not inside a Muse repo."""
396 from typer.testing import CliRunner
397 from maestro.muse_cli.app import cli
398
399 runner = CliRunner()
400 result = runner.invoke(cli, ["amend", "--no-edit"], catch_exceptions=False)
401 assert result.exit_code == ExitCode.REPO_NOT_FOUND
402
403
404 # ---------------------------------------------------------------------------
405 # Amended commit is stored in DB and head ref is updated
406 # ---------------------------------------------------------------------------
407
408
409 @pytest.mark.anyio
410 async def test_muse_amend_new_commit_stored_in_db(
411 tmp_path: pathlib.Path, muse_cli_db_session: AsyncSession
412 ) -> None:
413 """The amended commit row exists in DB with correct repo_id and branch."""
414 rid = _init_muse_repo(tmp_path)
415 await _make_commit(tmp_path, muse_cli_db_session, message="before amend")
416
417 (tmp_path / "muse-work" / "beat.mid").write_bytes(b"POST-AMEND")
418
419 amended_id = await _amend_async(
420 message="after amend",
421 no_edit=False,
422 reset_author=False,
423 root=tmp_path,
424 session=muse_cli_db_session,
425 )
426
427 result = await muse_cli_db_session.execute(
428 select(MuseCliCommit).where(MuseCliCommit.commit_id == amended_id)
429 )
430 row = result.scalar_one_or_none()
431 assert row is not None
432 assert row.repo_id == rid
433 assert row.branch == "main"
434 assert row.message == "after amend"