cgcardona / muse public
test_update_ref.py python
403 lines 13.3 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Tests for ``muse update-ref`` — write or delete a ref (branch or tag pointer).
2
3 Verifies:
4 - update_ref: writes new commit_id to refs/heads/<branch>.
5 - update_ref: writes new commit_id to refs/tags/<tag>.
6 - update_ref: validates commit exists before writing.
7 - update_ref: CAS guard (--old-value) passes when current matches expected.
8 - update_ref: CAS guard exits USER_ERROR when current does not match expected.
9 - update_ref: CAS guard handles missing ref (current=None vs provided old-value).
10 - delete_ref: removes an existing ref file.
11 - delete_ref: exits USER_ERROR when ref file does not exist.
12 - update_ref: exits USER_ERROR for invalid ref format.
13 - delete_ref: exits USER_ERROR for invalid ref format.
14 - Boundary seal (AST): ``from __future__ import annotations`` present.
15 """
16 from __future__ import annotations
17
18 import ast
19 import datetime
20 import json
21 import pathlib
22 import uuid
23 from collections.abc import AsyncGenerator
24
25 import pytest
26 import typer
27 from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
28
29 from maestro.db.database import Base
30 from maestro.muse_cli import models as cli_models # noqa: F401 — register tables
31 from maestro.muse_cli.errors import ExitCode
32 from maestro.muse_cli.models import MuseCliCommit, MuseCliObject, MuseCliSnapshot
33 from maestro.muse_cli.commands.update_ref import (
34 _delete_ref_async,
35 _update_ref_async,
36 _validate_ref_format,
37 )
38
39
40 # ---------------------------------------------------------------------------
41 # Fixtures
42 # ---------------------------------------------------------------------------
43
44
45 @pytest.fixture
46 async def async_session() -> AsyncGenerator[AsyncSession, None]:
47 """In-memory SQLite session with all CLI tables created."""
48 engine = create_async_engine("sqlite+aiosqlite:///:memory:")
49 async with engine.begin() as conn:
50 await conn.run_sync(Base.metadata.create_all)
51 Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
52 async with Session() as session:
53 yield session
54 await engine.dispose()
55
56
57 @pytest.fixture
58 def repo_root(tmp_path: pathlib.Path) -> pathlib.Path:
59 """Create a minimal Muse repo structure under *tmp_path*."""
60 muse_dir = tmp_path / ".muse"
61 muse_dir.mkdir()
62 (muse_dir / "HEAD").write_text("refs/heads/main")
63 refs_heads = muse_dir / "refs" / "heads"
64 refs_heads.mkdir(parents=True)
65 refs_tags = muse_dir / "refs" / "tags"
66 refs_tags.mkdir(parents=True)
67 repo_id = str(uuid.uuid4())
68 (muse_dir / "repo.json").write_text(json.dumps({"repo_id": repo_id}))
69 return tmp_path
70
71
72 async def _insert_commit(
73 session: AsyncSession,
74 repo_root: pathlib.Path,
75 commit_suffix: str = "b",
76 branch: str = "main",
77 ) -> str:
78 """Insert a minimal MuseCliCommit and return its commit_id."""
79 repo_json = repo_root / ".muse" / "repo.json"
80 repo_id: str = json.loads(repo_json.read_text())["repo_id"]
81
82 obj_id = (commit_suffix * 2)[:2] + "c" * 62
83 snap_id = (commit_suffix * 2)[:2] + "a" * 62
84 commit_id = commit_suffix * 64
85
86 session.add(MuseCliObject(object_id=obj_id, size_bytes=1))
87 session.add(MuseCliSnapshot(snapshot_id=snap_id, manifest={"f.mid": obj_id}))
88 await session.flush()
89
90 session.add(
91 MuseCliCommit(
92 commit_id=commit_id,
93 repo_id=repo_id,
94 branch=branch,
95 parent_commit_id=None,
96 parent2_commit_id=None,
97 snapshot_id=snap_id,
98 message="initial",
99 author="",
100 committed_at=datetime.datetime.now(datetime.timezone.utc),
101 )
102 )
103 await session.flush()
104 return commit_id
105
106
107 # ---------------------------------------------------------------------------
108 # _validate_ref_format
109 # ---------------------------------------------------------------------------
110
111
112 def test_validate_ref_format_accepts_heads() -> None:
113 """refs/heads/<name> must not raise."""
114 _validate_ref_format("refs/heads/main") # no exception
115
116
117 def test_validate_ref_format_accepts_tags() -> None:
118 """refs/tags/<name> must not raise."""
119 _validate_ref_format("refs/tags/v1.0") # no exception
120
121
122 def test_validate_ref_format_rejects_bare_name() -> None:
123 """A bare name without prefix must exit USER_ERROR."""
124 with pytest.raises(typer.Exit) as exc_info:
125 _validate_ref_format("main")
126 assert exc_info.value.exit_code == ExitCode.USER_ERROR
127
128
129 def test_validate_ref_format_rejects_wrong_prefix() -> None:
130 """An arbitrary prefix must exit USER_ERROR."""
131 with pytest.raises(typer.Exit) as exc_info:
132 _validate_ref_format("refs/remotes/origin/main")
133 assert exc_info.value.exit_code == ExitCode.USER_ERROR
134
135
136 # ---------------------------------------------------------------------------
137 # _update_ref_async — basic write
138 # ---------------------------------------------------------------------------
139
140
141 @pytest.mark.anyio
142 async def test_update_ref_writes_commit_id_to_heads(
143 async_session: AsyncSession,
144 repo_root: pathlib.Path,
145 ) -> None:
146 """update-ref writes the commit_id to refs/heads/<branch>."""
147 commit_id = await _insert_commit(async_session, repo_root)
148
149 await _update_ref_async(
150 ref="refs/heads/main",
151 new_value=commit_id,
152 old_value=None,
153 root=repo_root,
154 session=async_session,
155 )
156
157 ref_path = repo_root / ".muse" / "refs" / "heads" / "main"
158 assert ref_path.read_text().strip() == commit_id
159
160
161 @pytest.mark.anyio
162 async def test_update_ref_writes_commit_id_to_tags(
163 async_session: AsyncSession,
164 repo_root: pathlib.Path,
165 ) -> None:
166 """update-ref writes the commit_id to refs/tags/<tag>."""
167 commit_id = await _insert_commit(async_session, repo_root)
168
169 await _update_ref_async(
170 ref="refs/tags/v1.0",
171 new_value=commit_id,
172 old_value=None,
173 root=repo_root,
174 session=async_session,
175 )
176
177 ref_path = repo_root / ".muse" / "refs" / "tags" / "v1.0"
178 assert ref_path.read_text().strip() == commit_id
179
180
181 @pytest.mark.anyio
182 async def test_update_ref_creates_parent_dirs(
183 async_session: AsyncSession,
184 repo_root: pathlib.Path,
185 ) -> None:
186 """update-ref creates intermediate directories if they don't exist."""
187 commit_id = await _insert_commit(async_session, repo_root)
188 # Remove the refs/tags dir to force creation
189 import shutil
190 shutil.rmtree(repo_root / ".muse" / "refs" / "tags")
191
192 await _update_ref_async(
193 ref="refs/tags/v2.0",
194 new_value=commit_id,
195 old_value=None,
196 root=repo_root,
197 session=async_session,
198 )
199
200 ref_path = repo_root / ".muse" / "refs" / "tags" / "v2.0"
201 assert ref_path.exists()
202 assert ref_path.read_text().strip() == commit_id
203
204
205 @pytest.mark.anyio
206 async def test_update_ref_validates_commit_exists(
207 async_session: AsyncSession,
208 repo_root: pathlib.Path,
209 ) -> None:
210 """update-ref exits USER_ERROR when the commit_id is not in the DB."""
211 fake_commit_id = "d" * 64
212
213 with pytest.raises(typer.Exit) as exc_info:
214 await _update_ref_async(
215 ref="refs/heads/main",
216 new_value=fake_commit_id,
217 old_value=None,
218 root=repo_root,
219 session=async_session,
220 )
221 assert exc_info.value.exit_code == ExitCode.USER_ERROR
222
223
224 # ---------------------------------------------------------------------------
225 # _update_ref_async — CAS guard
226 # ---------------------------------------------------------------------------
227
228
229 @pytest.mark.anyio
230 async def test_update_ref_cas_passes_when_current_matches(
231 async_session: AsyncSession,
232 repo_root: pathlib.Path,
233 ) -> None:
234 """CAS succeeds and writes the new value when old-value matches current."""
235 commit_id_v1 = await _insert_commit(async_session, repo_root, commit_suffix="b")
236 commit_id_v2 = await _insert_commit(async_session, repo_root, commit_suffix="c")
237
238 # Prime the ref with v1.
239 ref_path = repo_root / ".muse" / "refs" / "heads" / "main"
240 ref_path.write_text(commit_id_v1)
241
242 await _update_ref_async(
243 ref="refs/heads/main",
244 new_value=commit_id_v2,
245 old_value=commit_id_v1,
246 root=repo_root,
247 session=async_session,
248 )
249
250 assert ref_path.read_text().strip() == commit_id_v2
251
252
253 @pytest.mark.anyio
254 async def test_update_ref_cas_fails_when_current_differs(
255 async_session: AsyncSession,
256 repo_root: pathlib.Path,
257 ) -> None:
258 """CAS exits USER_ERROR when old-value does not match current ref."""
259 commit_id_v1 = await _insert_commit(async_session, repo_root, commit_suffix="b")
260 commit_id_v2 = await _insert_commit(async_session, repo_root, commit_suffix="c")
261 commit_id_v3 = await _insert_commit(async_session, repo_root, commit_suffix="e")
262
263 # Prime the ref with v3 (not v1).
264 ref_path = repo_root / ".muse" / "refs" / "heads" / "main"
265 ref_path.write_text(commit_id_v3)
266
267 with pytest.raises(typer.Exit) as exc_info:
268 await _update_ref_async(
269 ref="refs/heads/main",
270 new_value=commit_id_v2,
271 old_value=commit_id_v1, # expects v1, but current is v3
272 root=repo_root,
273 session=async_session,
274 )
275 assert exc_info.value.exit_code == ExitCode.USER_ERROR
276 # Ref must not have been modified.
277 assert ref_path.read_text().strip() == commit_id_v3
278
279
280 @pytest.mark.anyio
281 async def test_update_ref_cas_fails_when_ref_missing_and_old_value_given(
282 async_session: AsyncSession,
283 repo_root: pathlib.Path,
284 ) -> None:
285 """CAS exits USER_ERROR when old-value is provided but the ref doesn't exist yet."""
286 commit_id = await _insert_commit(async_session, repo_root)
287
288 # Ref file does not exist — current is None.
289 ref_path = repo_root / ".muse" / "refs" / "heads" / "new-branch"
290 assert not ref_path.exists()
291
292 with pytest.raises(typer.Exit) as exc_info:
293 await _update_ref_async(
294 ref="refs/heads/new-branch",
295 new_value=commit_id,
296 old_value="a" * 64, # expects something, but ref is absent
297 root=repo_root,
298 session=async_session,
299 )
300 assert exc_info.value.exit_code == ExitCode.USER_ERROR
301
302
303 # ---------------------------------------------------------------------------
304 # _delete_ref_async
305 # ---------------------------------------------------------------------------
306
307
308 @pytest.mark.anyio
309 async def test_delete_ref_removes_existing_ref(
310 repo_root: pathlib.Path,
311 ) -> None:
312 """delete_ref removes the ref file when it exists."""
313 ref_path = repo_root / ".muse" / "refs" / "heads" / "feature"
314 ref_path.write_text("b" * 64)
315
316 await _delete_ref_async(ref="refs/heads/feature", root=repo_root)
317
318 assert not ref_path.exists()
319
320
321 @pytest.mark.anyio
322 async def test_delete_ref_removes_tag_ref(
323 repo_root: pathlib.Path,
324 ) -> None:
325 """delete_ref works for tag refs (refs/tags/*)."""
326 ref_path = repo_root / ".muse" / "refs" / "tags" / "v1.0"
327 ref_path.write_text("c" * 64)
328
329 await _delete_ref_async(ref="refs/tags/v1.0", root=repo_root)
330
331 assert not ref_path.exists()
332
333
334 @pytest.mark.anyio
335 async def test_delete_ref_exits_user_error_when_missing(
336 repo_root: pathlib.Path,
337 ) -> None:
338 """delete_ref exits USER_ERROR when the ref file does not exist."""
339 with pytest.raises(typer.Exit) as exc_info:
340 await _delete_ref_async(ref="refs/heads/ghost", root=repo_root)
341 assert exc_info.value.exit_code == ExitCode.USER_ERROR
342
343
344 @pytest.mark.anyio
345 async def test_delete_ref_exits_user_error_invalid_format(
346 repo_root: pathlib.Path,
347 ) -> None:
348 """delete_ref exits USER_ERROR when the ref format is invalid."""
349 with pytest.raises(typer.Exit) as exc_info:
350 await _delete_ref_async(ref="HEAD", root=repo_root)
351 assert exc_info.value.exit_code == ExitCode.USER_ERROR
352
353
354 # ---------------------------------------------------------------------------
355 # Regression: update overwrites existing value
356 # ---------------------------------------------------------------------------
357
358
359 @pytest.mark.anyio
360 async def test_update_ref_overwrites_existing_value(
361 async_session: AsyncSession,
362 repo_root: pathlib.Path,
363 ) -> None:
364 """A second call to update-ref overwrites the first value (no CAS)."""
365 commit_id_v1 = await _insert_commit(async_session, repo_root, commit_suffix="b")
366 commit_id_v2 = await _insert_commit(async_session, repo_root, commit_suffix="c")
367
368 ref_path = repo_root / ".muse" / "refs" / "heads" / "main"
369 ref_path.write_text(commit_id_v1)
370
371 await _update_ref_async(
372 ref="refs/heads/main",
373 new_value=commit_id_v2,
374 old_value=None,
375 root=repo_root,
376 session=async_session,
377 )
378 assert ref_path.read_text().strip() == commit_id_v2
379
380
381 # ---------------------------------------------------------------------------
382 # Boundary seal
383 # ---------------------------------------------------------------------------
384
385
386 def test_update_ref_module_future_annotations_present() -> None:
387 """update_ref.py must start with 'from __future__ import annotations'."""
388 import maestro.muse_cli.commands.update_ref as module
389
390 assert module.__file__ is not None
391 source_path = pathlib.Path(module.__file__)
392 tree = ast.parse(source_path.read_text())
393 first_import = next(
394 (
395 node
396 for node in ast.walk(tree)
397 if isinstance(node, ast.ImportFrom) and node.module == "__future__"
398 ),
399 None,
400 )
401 assert first_import is not None, "Missing 'from __future__ import annotations'"
402 names = [alias.name for alias in first_import.names]
403 assert "annotations" in names