gabriel / musehub public
test_on_disk_refs.py python
621 lines 21.2 KB
Raw
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor ⚠ breaking 146 days ago
1 """Phase 3: On-disk refs as canonical branch pointers — TDD (RED → GREEN).
2
3 Seven tiers:
4 Tier 1 — write_ref writes refs/heads/<branch> via atomic rename
5 Tier 2 — read_ref round-trips what write_ref wrote
6 Tier 3 — read_ref returns None for missing branches
7 Tier 4 — write_ref creates parent dirs on first write
8 Tier 5 — write_ref is truly atomic (tmp file disappears, final file appears)
9 Tier 6 — wire_push_stream writes disk ref after DB commit
10 Tier 7 — GET /repos/{repo_id}/branches/{name}/repair heals DB from disk ref
11 """
12 from __future__ import annotations
13
14 import uuid
15 from collections.abc import AsyncGenerator
16 from pathlib import Path
17 from unittest.mock import patch
18
19 import pytest
20 import pytest_asyncio
21 from httpx import AsyncClient, ASGITransport
22 from sqlalchemy.ext.asyncio import AsyncSession
23
24 from musehub.auth.request_signing import MSignContext, optional_signed_request, require_signed_request
25 from musehub.core.genesis import compute_identity_id
26 from musehub.db.musehub_models import MusehubIdentity, MusehubRepo
27 from musehub.main import app
28
29
30 # ── helpers ───────────────────────────────────────────────────────────────────
31
32 def _oid() -> str:
33 """Return a valid sha256-prefixed object ID (128 hex chars)."""
34 return "sha256:" + (uuid.uuid4().hex * 2)
35
36
37 def _repo_root(tmp_path: Path, owner: str = "gabriel", slug: str = "test-repo") -> Path:
38 """Create a minimal server-side repo tree and return its root."""
39 root = tmp_path / owner / slug
40 (root / "refs" / "heads").mkdir(parents=True, exist_ok=True)
41 (root / "objects").mkdir(parents=True, exist_ok=True)
42 return root
43
44
45 # ── fixtures for Tier 6/7 integration tests ───────────────────────────────────
46
47 _OWNER = "ref-test-user"
48 _SLUG = "ref-test-repo"
49 _IDENTITY_ID = compute_identity_id(b"ref-test-user")
50
51 _TEST_CONTEXT = MSignContext(
52 handle=_OWNER,
53 identity_id=_IDENTITY_ID,
54 is_agent=False,
55 is_admin=False,
56 )
57
58
59 @pytest_asyncio.fixture
60 async def async_session(db_session: AsyncSession) -> AsyncSession:
61 """Alias: expose the conftest db_session as async_session."""
62 return db_session
63
64
65 @pytest_asyncio.fixture
66 async def async_client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
67 """Async HTTP client wired to the test app + DB."""
68 from tests.conftest import _Asgi24Wrapper
69 transport = ASGITransport(app=_Asgi24Wrapper(app))
70 async with AsyncClient(transport=transport, base_url="http://test") as ac:
71 yield ac
72
73
74 @pytest_asyncio.fixture
75 async def owner(db_session: AsyncSession) -> str:
76 """Create a test identity and return its handle."""
77 identity = MusehubIdentity(
78 identity_id=_IDENTITY_ID,
79 handle=_OWNER,
80 display_name="Ref Test User",
81 identity_type="human",
82 )
83 db_session.add(identity)
84 await db_session.commit()
85 return _OWNER
86
87
88 @pytest_asyncio.fixture
89 async def slug() -> str:
90 return _SLUG
91
92
93 @pytest_asyncio.fixture
94 async def repo_id(owner: str, db_session: AsyncSession) -> str:
95 """Create a minimal test repo row and return its repo_id."""
96 from datetime import datetime, timezone
97 from musehub.core.genesis import compute_repo_id, compute_branch_id
98 from musehub.db.musehub_models import MusehubBranch
99
100 created_at = datetime.now(tz=timezone.utc)
101 rid = compute_repo_id(_IDENTITY_ID, _SLUG, "code", created_at.isoformat())
102 repo = MusehubRepo(
103 repo_id=rid,
104 owner=owner,
105 slug=_SLUG,
106 name="Ref Test Repo",
107 owner_user_id=_IDENTITY_ID,
108 visibility="public",
109 default_branch="main",
110 created_at=created_at,
111 )
112 db_session.add(repo)
113 await db_session.commit()
114 branch = MusehubBranch(
115 branch_id=compute_branch_id(rid, "main"),
116 repo_id=rid,
117 name="main",
118 )
119 db_session.add(branch)
120 await db_session.commit()
121 return rid
122
123
124 @pytest.fixture
125 def authed_headers(owner: str) -> dict:
126 """Inject auth context and return minimal JSON headers."""
127 app.dependency_overrides[require_signed_request] = lambda: _TEST_CONTEXT
128 app.dependency_overrides[optional_signed_request] = lambda: _TEST_CONTEXT
129 yield {"Content-Type": "application/json"}
130 app.dependency_overrides.pop(require_signed_request, None)
131 app.dependency_overrides.pop(optional_signed_request, None)
132
133
134 # ── Tier 1: write_ref writes refs/heads/<branch> ─────────────────────────────
135
136 class TestWriteRefCreatesFile:
137 def test_write_ref_creates_ref_file(self, tmp_path: Path) -> None:
138 from musehub.storage.refs import write_ref
139 from muse.core.paths import server_ref_path
140
141 repo_root = _repo_root(tmp_path)
142 commit_id = _oid()
143
144 write_ref(repo_root, "main", commit_id)
145
146 ref_file = server_ref_path(repo_root, "main")
147 assert ref_file.exists(), "ref file must exist after write_ref"
148
149 def test_write_ref_content_is_commit_id_with_newline(self, tmp_path: Path) -> None:
150 from musehub.storage.refs import write_ref
151 from muse.core.paths import server_ref_path
152
153 repo_root = _repo_root(tmp_path)
154 commit_id = _oid()
155
156 write_ref(repo_root, "main", commit_id)
157
158 ref_file = server_ref_path(repo_root, "main")
159 assert ref_file.read_text() == f"{commit_id}\n"
160
161 def test_write_ref_overwrites_existing(self, tmp_path: Path) -> None:
162 from musehub.storage.refs import write_ref
163 from muse.core.paths import server_ref_path
164
165 repo_root = _repo_root(tmp_path)
166 old_id = _oid()
167 new_id = _oid()
168
169 write_ref(repo_root, "main", old_id)
170 write_ref(repo_root, "main", new_id)
171
172 ref_file = server_ref_path(repo_root, "main")
173 assert ref_file.read_text() == f"{new_id}\n"
174
175 def test_write_ref_supports_feature_branches(self, tmp_path: Path) -> None:
176 from musehub.storage.refs import write_ref
177 from muse.core.paths import server_ref_path
178
179 repo_root = _repo_root(tmp_path)
180 commit_id = _oid()
181
182 write_ref(repo_root, "feat/new-melody", commit_id)
183
184 ref_file = server_ref_path(repo_root, "feat/new-melody")
185 assert ref_file.exists()
186 assert ref_file.read_text() == f"{commit_id}\n"
187
188
189 # ── Tier 2: read_ref round-trips ─────────────────────────────────────────────
190
191 class TestReadRefRoundTrips:
192 def test_read_ref_returns_commit_id(self, tmp_path: Path) -> None:
193 from musehub.storage.refs import write_ref, read_ref
194
195 repo_root = _repo_root(tmp_path)
196 commit_id = _oid()
197
198 write_ref(repo_root, "main", commit_id)
199 result = read_ref(repo_root, "main")
200
201 assert result == commit_id
202
203 def test_read_ref_strips_trailing_newline(self, tmp_path: Path) -> None:
204 from musehub.storage.refs import read_ref
205 from muse.core.paths import server_ref_path
206
207 repo_root = _repo_root(tmp_path)
208 commit_id = _oid()
209 ref_file = server_ref_path(repo_root, "main")
210 ref_file.write_text(f"{commit_id}\n")
211
212 result = read_ref(repo_root, "main")
213 assert result == commit_id
214
215 def test_read_ref_round_trips_feature_branch(self, tmp_path: Path) -> None:
216 from musehub.storage.refs import write_ref, read_ref
217
218 repo_root = _repo_root(tmp_path)
219 commit_id = _oid()
220
221 write_ref(repo_root, "feat/jazz-changes", commit_id)
222 result = read_ref(repo_root, "feat/jazz-changes")
223
224 assert result == commit_id
225
226 def test_read_ref_multiple_branches_independent(self, tmp_path: Path) -> None:
227 from musehub.storage.refs import write_ref, read_ref
228
229 repo_root = _repo_root(tmp_path)
230 main_id = _oid()
231 dev_id = _oid()
232
233 write_ref(repo_root, "main", main_id)
234 write_ref(repo_root, "dev", dev_id)
235
236 assert read_ref(repo_root, "main") == main_id
237 assert read_ref(repo_root, "dev") == dev_id
238
239
240 # ── Tier 3: read_ref returns None for missing ─────────────────────────────────
241
242 class TestReadRefMissing:
243 def test_read_ref_returns_none_for_unknown_branch(self, tmp_path: Path) -> None:
244 from musehub.storage.refs import read_ref
245
246 repo_root = _repo_root(tmp_path)
247
248 assert read_ref(repo_root, "nonexistent") is None
249
250 def test_read_ref_returns_none_empty_repo(self, tmp_path: Path) -> None:
251 from musehub.storage.refs import read_ref
252
253 repo_root = _repo_root(tmp_path)
254
255 assert read_ref(repo_root, "main") is None
256
257 def test_read_ref_returns_none_after_branch_deleted_from_disk(
258 self, tmp_path: Path
259 ) -> None:
260 from musehub.storage.refs import write_ref, read_ref
261 from muse.core.paths import server_ref_path
262
263 repo_root = _repo_root(tmp_path)
264 commit_id = _oid()
265 write_ref(repo_root, "temp-branch", commit_id)
266
267 server_ref_path(repo_root, "temp-branch").unlink()
268
269 assert read_ref(repo_root, "temp-branch") is None
270
271
272 # ── Tier 4: write_ref creates parent dirs ────────────────────────────────────
273
274 class TestWriteRefCreatesParentDirs:
275 def test_write_ref_creates_refs_heads_dir(self, tmp_path: Path) -> None:
276 from musehub.storage.refs import write_ref
277
278 # Start with only the objects dir — no refs/heads/ yet
279 repo_root = tmp_path / "owner" / "repo"
280 (repo_root / "objects").mkdir(parents=True)
281
282 commit_id = _oid()
283 write_ref(repo_root, "main", commit_id)
284
285 assert (repo_root / "refs" / "heads" / "main").exists()
286
287 def test_write_ref_creates_nested_branch_dirs(self, tmp_path: Path) -> None:
288 from musehub.storage.refs import write_ref
289
290 repo_root = _repo_root(tmp_path)
291 commit_id = _oid()
292
293 write_ref(repo_root, "feat/new/nested", commit_id)
294
295 assert (repo_root / "refs" / "heads" / "feat" / "new" / "nested").exists()
296
297
298 # ── Tier 5: atomic write (tmp → rename) ──────────────────────────────────────
299
300 class TestWriteRefAtomic:
301 def test_no_tmp_file_after_write(self, tmp_path: Path) -> None:
302 from musehub.storage.refs import write_ref
303 from muse.core.paths import server_ref_path
304
305 repo_root = _repo_root(tmp_path)
306 commit_id = _oid()
307
308 write_ref(repo_root, "main", commit_id)
309
310 ref_file = server_ref_path(repo_root, "main")
311 tmp_file = ref_file.with_suffix(".tmp")
312 assert not tmp_file.exists(), ".tmp sentinel must be gone after rename"
313
314 def test_partial_write_does_not_corrupt_existing_ref(
315 self, tmp_path: Path
316 ) -> None:
317 """A crash mid-write (simulated by leaving a .tmp) must not corrupt the ref."""
318 from musehub.storage.refs import read_ref
319 from muse.core.paths import server_ref_path
320
321 repo_root = _repo_root(tmp_path)
322 good_id = _oid()
323
324 # Write a good ref first
325 ref_file = server_ref_path(repo_root, "main")
326 ref_file.write_text(f"{good_id}\n")
327
328 # Simulate a crash: write a .tmp but never rename
329 tmp_file = ref_file.with_suffix(".tmp")
330 tmp_file.write_text("sha256:" + "ff" * 32 + "\n")
331
332 # The ref still reads the good value
333 assert read_ref(repo_root, "main") == good_id
334
335
336 # ── Tier 6: wire_push_stream writes disk ref ─────────────────────────────────
337
338 import msgpack as _msgpack
339 from muse.core.types import blob_id as _blob_id
340 from muse.core.mpack import MuseWireFrameWriter as _MuseWireFrameWriter
341 from musehub.models.wire import (
342 SFRAME_COMMIT_PACK,
343 SFRAME_END,
344 SFRAME_HEADER,
345 SFRAME_RESULT,
346 )
347
348 _fw = _MuseWireFrameWriter()
349
350
351 def _mwp_wrap(ft: str, data: dict) -> bytes:
352 payload = _msgpack.packb(data, use_bin_type=True)
353 return _fw.wrap(frame_type=ft, payload=payload)
354
355
356 def _mwp_header(branch: str = "main", n_commits: int = 1) -> bytes:
357 snap_id = _blob_id(b"default-snap")
358 return _mwp_wrap(SFRAME_HEADER, {
359 "t": SFRAME_HEADER, "branch": branch, "force": False,
360 "have": [], "head": snap_id, "n_objects": 0, "n_commits": n_commits,
361 })
362
363
364 def _mwp_commit_pack(commits: list[dict], snapshots: list[dict]) -> bytes:
365 return _mwp_wrap(SFRAME_COMMIT_PACK, {
366 "t": SFRAME_COMMIT_PACK, "commits": commits, "snapshots": snapshots,
367 })
368
369
370 def _mwp_end(n_commits: int = 1) -> bytes:
371 return _mwp_wrap(SFRAME_END, {"t": SFRAME_END, "n_objects": 0, "n_commits": n_commits})
372
373
374 def _make_commit_wire(commit_id: str, snap_id: str, author: str = _OWNER) -> dict:
375 from datetime import datetime, timezone
376 return {
377 "commit_id": commit_id,
378 "parent_ids": [],
379 "parent_commit_id": None,
380 "parent2_commit_id": None,
381 "snapshot_id": snap_id,
382 "branch": "main",
383 "message": "disk ref test commit",
384 "author": author,
385 "committed_at": datetime.now(tz=timezone.utc).isoformat(),
386 "signature": "",
387 "signer_key_id": "",
388 "agent_id": "",
389 "model_id": "",
390 "metadata": {},
391 }
392
393
394 def _make_snap_wire(snap_id: str) -> dict:
395 from datetime import datetime, timezone
396 return {
397 "snapshot_id": snap_id,
398 "manifest": {},
399 "committed_at": datetime.now(tz=timezone.utc).isoformat(),
400 }
401
402
403 async def _run_push(db_session: AsyncSession, repo_id: str, owner: str, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> str:
404 """Run a minimal push via wire_push_stream and return the pushed commit_id."""
405 from unittest.mock import AsyncMock
406 from musehub.services.musehub_wire import wire_push_stream
407 from musehub.config import settings
408
409 # Stub the R2 backend
410 store: dict[str, bytes] = {}
411 backend = AsyncMock()
412 backend.exists = AsyncMock(side_effect=lambda oid, **kw: oid in store)
413 backend.put = AsyncMock(side_effect=lambda oid, data, **kw: store.update({oid: data}) or f"local://{oid}")
414 backend.get = AsyncMock(side_effect=lambda oid, **kw: store.get(oid))
415 monkeypatch.setattr("musehub.services.musehub_wire.get_backend", lambda: backend)
416
417 # Redirect repos_dir to tmp_path so disk refs land there
418 monkeypatch.setattr(settings, "musehub_repos_dir", str(tmp_path))
419
420 commit_id = _blob_id(f"ref-test-commit-{uuid.uuid4()}".encode())
421 snap_id = _blob_id(b"ref-test-snap")
422 commit = _make_commit_wire(commit_id, snap_id, owner)
423 snap = _make_snap_wire(snap_id)
424
425 body_frames = _mwp_header() + _mwp_commit_pack([commit], [snap]) + _mwp_end()
426
427 async def body_iter():
428 yield body_frames
429
430 results = []
431 unpacker = _msgpack.Unpacker(raw=False)
432 async for chunk in wire_push_stream(db_session, repo_id, body_iter(), owner):
433 unpacker.feed(chunk)
434 for frame in unpacker:
435 results.append(frame)
436
437 result_frames = [f for f in results if f.get("t") == SFRAME_RESULT]
438 assert result_frames and result_frames[0]["ok"] is True, f"push failed: {results}"
439 return commit_id
440
441
442 class TestWirePushWritesDiskRef:
443 """Integration: after a push the ref file must exist on disk."""
444
445 @pytest.mark.asyncio
446 async def test_push_writes_disk_ref(
447 self,
448 tmp_path: Path,
449 async_session: AsyncSession,
450 repo_id: str,
451 owner: str,
452 slug: str,
453 monkeypatch: pytest.MonkeyPatch,
454 ) -> None:
455 """After a successful push, refs/heads/main must exist on disk."""
456 from musehub.storage.refs import read_ref
457
458 commit_id = await _run_push(async_session, repo_id, owner, monkeypatch, tmp_path)
459
460 repo_root = tmp_path / owner / slug
461 result = read_ref(repo_root, "main")
462 assert result == commit_id
463
464 @pytest.mark.asyncio
465 async def test_push_disk_ref_matches_db_head(
466 self,
467 tmp_path: Path,
468 async_session: AsyncSession,
469 repo_id: str,
470 owner: str,
471 slug: str,
472 monkeypatch: pytest.MonkeyPatch,
473 ) -> None:
474 """The disk ref commit_id must equal the DB branch head commit_id."""
475 from musehub.storage.refs import read_ref
476 from musehub.services.musehub_repository import get_branch_head_commit_id
477
478 commit_id = await _run_push(async_session, repo_id, owner, monkeypatch, tmp_path)
479
480 repo_root = tmp_path / owner / slug
481 disk_head = read_ref(repo_root, "main")
482 db_head = await get_branch_head_commit_id(async_session, repo_id, "main")
483 assert disk_head == commit_id
484 assert db_head == commit_id
485
486
487 # ── Tier 7: repair endpoint ───────────────────────────────────────────────────
488
489 class TestRepairEndpoint:
490 """GET /repos/{repo_id}/branches/{name}/repair must heal DB from disk ref."""
491
492 @pytest.mark.asyncio
493 async def test_repair_heals_db_from_disk(
494 self,
495 tmp_path: Path,
496 async_client: AsyncClient,
497 async_session: AsyncSession,
498 authed_headers: dict,
499 repo_id: str,
500 owner: str,
501 slug: str,
502 ) -> None:
503 """When disk ref != DB head, repair updates DB to match disk."""
504 from musehub.storage.refs import write_ref
505 from musehub.services.musehub_repository import get_branch_head_commit_id
506 import musehub.db.musehub_models as models
507 from sqlalchemy import select, update
508
509 canonical_id = _oid()
510 stale_id = _oid()
511
512 with patch(
513 "musehub.storage.backends.settings.musehub_repos_dir",
514 str(tmp_path),
515 ):
516 repo_root = tmp_path / owner / slug
517 # Write the canonical commit to disk
518 write_ref(repo_root, "main", canonical_id)
519
520 # Manually set a stale value in DB
521 await async_session.execute(
522 update(models.MusehubBranch)
523 .where(
524 models.MusehubBranch.repo_id == repo_id,
525 models.MusehubBranch.name == "main",
526 )
527 .values(head_commit_id=stale_id)
528 )
529 await async_session.commit()
530
531 response = await async_client.post(
532 f"/api/repos/{repo_id}/branches/main/repair",
533 headers=authed_headers,
534 )
535 assert response.status_code == 200
536 body = response.json()
537 assert body["healed"] is True
538 assert body["commit_id"] == canonical_id
539
540 # DB must now match disk
541 db_head = await get_branch_head_commit_id(async_session, repo_id, "main")
542 assert db_head == canonical_id
543
544 @pytest.mark.asyncio
545 async def test_repair_noop_when_already_consistent(
546 self,
547 tmp_path: Path,
548 async_client: AsyncClient,
549 async_session: AsyncSession,
550 authed_headers: dict,
551 repo_id: str,
552 owner: str,
553 slug: str,
554 ) -> None:
555 """When disk and DB agree, repair returns healed=False."""
556 from musehub.storage.refs import write_ref
557 import musehub.db.musehub_models as models
558 from sqlalchemy import update
559
560 commit_id = _oid()
561
562 with patch(
563 "musehub.storage.backends.settings.musehub_repos_dir",
564 str(tmp_path),
565 ):
566 repo_root = tmp_path / owner / slug
567 write_ref(repo_root, "main", commit_id)
568
569 await async_session.execute(
570 update(models.MusehubBranch)
571 .where(
572 models.MusehubBranch.repo_id == repo_id,
573 models.MusehubBranch.name == "main",
574 )
575 .values(head_commit_id=commit_id)
576 )
577 await async_session.commit()
578
579 response = await async_client.post(
580 f"/api/repos/{repo_id}/branches/main/repair",
581 headers=authed_headers,
582 )
583 assert response.status_code == 200
584 body = response.json()
585 assert body["healed"] is False
586
587 @pytest.mark.asyncio
588 async def test_repair_404_when_no_disk_ref(
589 self,
590 tmp_path: Path,
591 async_client: AsyncClient,
592 authed_headers: dict,
593 repo_id: str,
594 ) -> None:
595 """Repair returns 404 when no disk ref exists to repair from."""
596 with patch(
597 "musehub.storage.backends.settings.musehub_repos_dir",
598 str(tmp_path),
599 ):
600 response = await async_client.post(
601 f"/api/repos/{repo_id}/branches/ghost/repair",
602 headers=authed_headers,
603 )
604 assert response.status_code == 404
605
606 @pytest.mark.asyncio
607 async def test_repair_requires_auth(
608 self,
609 tmp_path: Path,
610 async_client: AsyncClient,
611 repo_id: str,
612 ) -> None:
613 """Repair endpoint must reject unauthenticated requests."""
614 with patch(
615 "musehub.storage.backends.settings.musehub_repos_dir",
616 str(tmp_path),
617 ):
618 response = await async_client.post(
619 f"/api/repos/{repo_id}/branches/main/repair",
620 )
621 assert response.status_code in (401, 403)
File History 1 commit
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor ⚠ 146 days ago