test_push_ff_check.py
python
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595
fix: typing audit — 0 violations, 0 untyped defs across all…
Sonnet 4.6
minor
⚠ breaking
22 days ago
| 1 | """TDD — server-side fast-forward check on push. |
| 2 | |
| 3 | Without this check the branch pointer is advanced unconditionally, meaning a |
| 4 | non-force push silently overwrites concurrent work. The force flag sent by |
| 5 | the client (muse push --force) is received but ignored. |
| 6 | |
| 7 | Test plan |
| 8 | --------- |
| 9 | FF-1 Normal FF push: remote at c1, client pushes c2 (parent=c1) → 200, branch=c2 |
| 10 | FF-2 Non-FF, no force: remote at c1, client pushes c2' (diverged) → 409, branch unchanged |
| 11 | FF-3 Non-FF, force=True: same diverged push with force=True → 200, branch=c2' |
| 12 | FF-4 New branch (no prior head): any push is always FF → 200 |
| 13 | FF-5 Push where incoming_head == current_head (no-op): → 200, branch unchanged |
| 14 | """ |
| 15 | from __future__ import annotations |
| 16 | |
| 17 | import datetime |
| 18 | import hashlib |
| 19 | import pathlib |
| 20 | |
| 21 | import httpx |
| 22 | import msgpack |
| 23 | import pytest |
| 24 | import pytest_asyncio |
| 25 | from httpx import AsyncClient, ASGITransport |
| 26 | from sqlalchemy import select |
| 27 | from sqlalchemy.ext.asyncio import AsyncSession |
| 28 | |
| 29 | from musehub.auth.request_signing import MSignContext, require_signed_request, optional_signed_request |
| 30 | from musehub.db.musehub_repo_models import MusehubBranch, MusehubRepo |
| 31 | from musehub.main import app |
| 32 | |
| 33 | from muse.core.mpack import build_mpack |
| 34 | from muse.core.object_store import write_object |
| 35 | from muse.core.paths import muse_dir |
| 36 | from muse.core.snapshot import compute_commit_id, compute_snapshot_id |
| 37 | from muse.core.store import ( |
| 38 | CommitRecord, |
| 39 | SnapshotRecord, |
| 40 | write_branch_ref, |
| 41 | write_commit, |
| 42 | write_snapshot, |
| 43 | ) |
| 44 | from muse.core.types import Manifest, blob_id |
| 45 | |
| 46 | |
| 47 | _AUTH_CTX = MSignContext( |
| 48 | handle="gabriel", |
| 49 | identity_id="sha256:" + "0" * 64, |
| 50 | is_agent=False, |
| 51 | is_admin=True, |
| 52 | ) |
| 53 | |
| 54 | |
| 55 | # --------------------------------------------------------------------------- |
| 56 | # Fixtures |
| 57 | # --------------------------------------------------------------------------- |
| 58 | |
| 59 | @pytest_asyncio.fixture() |
| 60 | async def client(db_session: AsyncSession): |
| 61 | # Only override auth — conftest.db_session already wires get_db to a |
| 62 | # per-request session that commits after each handler, so our test |
| 63 | # db_session sees committed data when it queries the DB directly. |
| 64 | app.dependency_overrides[require_signed_request] = lambda: _AUTH_CTX |
| 65 | app.dependency_overrides[optional_signed_request] = lambda: _AUTH_CTX |
| 66 | |
| 67 | async with AsyncClient( |
| 68 | transport=ASGITransport(app=app), |
| 69 | base_url="https://localhost:1337", |
| 70 | ) as c: |
| 71 | yield c |
| 72 | |
| 73 | app.dependency_overrides.pop(require_signed_request, None) |
| 74 | app.dependency_overrides.pop(optional_signed_request, None) |
| 75 | |
| 76 | |
| 77 | @pytest_asyncio.fixture() |
| 78 | async def repo(client: AsyncClient): |
| 79 | resp = await client.post( |
| 80 | "/api/repos", |
| 81 | json={"owner": "gabriel", "name": "ff-check-test", "visibility": "public", "initialize": False}, |
| 82 | ) |
| 83 | assert resp.status_code in (200, 201), resp.text |
| 84 | data = resp.json() |
| 85 | yield data["slug"] |
| 86 | await client.delete(f"/api/repos/{data['repoId']}") |
| 87 | |
| 88 | |
| 89 | # --------------------------------------------------------------------------- |
| 90 | # Helpers |
| 91 | # --------------------------------------------------------------------------- |
| 92 | |
| 93 | def _make_local_repo(tmp: pathlib.Path, repo_id: str = "ff-test") -> pathlib.Path: |
| 94 | tmp.mkdir(parents=True, exist_ok=True) |
| 95 | dot = muse_dir(tmp) |
| 96 | dot.mkdir() |
| 97 | (dot / "repo.json").write_text(f'{{"repo_id":"{repo_id}","owner":"gabriel"}}') |
| 98 | for d in ("commits", "snapshots", "objects"): |
| 99 | (dot / d).mkdir() |
| 100 | (dot / "refs" / "heads").mkdir(parents=True) |
| 101 | (dot / "HEAD").write_text("ref: refs/heads/main\n") |
| 102 | (dot / "config.toml").write_text("") |
| 103 | return tmp |
| 104 | |
| 105 | |
| 106 | def _add_commit( |
| 107 | root: pathlib.Path, |
| 108 | label: str, |
| 109 | parent_id: str | None = None, |
| 110 | repo_id: str = "ff-test", |
| 111 | ) -> CommitRecord: |
| 112 | raw = f"content-{label}".encode() |
| 113 | oid = blob_id(raw) |
| 114 | write_object(root, oid, raw) |
| 115 | manifest: Manifest = {"file.txt": oid} |
| 116 | snap_id = compute_snapshot_id(manifest) |
| 117 | write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest)) |
| 118 | ts = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) |
| 119 | parent_ids = [parent_id] if parent_id else [] |
| 120 | cid = compute_commit_id( |
| 121 | parent_ids=parent_ids, |
| 122 | snapshot_id=snap_id, |
| 123 | message=f"commit {label}", |
| 124 | committed_at_iso=ts.isoformat(), |
| 125 | author="gabriel", |
| 126 | ) |
| 127 | commit = CommitRecord( |
| 128 | repo_id=repo_id, |
| 129 | commit_id=cid, |
| 130 | branch="main", |
| 131 | snapshot_id=snap_id, |
| 132 | message=f"commit {label}", |
| 133 | committed_at=ts, |
| 134 | parent_commit_id=parent_id, |
| 135 | parent2_commit_id=None, |
| 136 | author="gabriel", |
| 137 | metadata={}, |
| 138 | structured_delta=None, |
| 139 | sem_ver_bump="none", |
| 140 | breaking_changes=[], |
| 141 | agent_id="", model_id="", toolchain_id="", |
| 142 | prompt_hash="", signature="", signer_key_id="", |
| 143 | ) |
| 144 | write_commit(root, commit) |
| 145 | return commit |
| 146 | |
| 147 | |
| 148 | async def _upload_and_unpack( |
| 149 | client: AsyncClient, |
| 150 | repo_slug: str, |
| 151 | root: pathlib.Path, |
| 152 | tip: str, |
| 153 | have: list[str], |
| 154 | *, |
| 155 | force: bool = False, |
| 156 | branch: str = "main", |
| 157 | expect_status: int = 200, |
| 158 | ) -> dict: |
| 159 | """Build mpack, presign-upload to MinIO, call unpack-mpack. Returns response JSON.""" |
| 160 | mpack_dict = build_mpack(root, [tip], have=have) |
| 161 | wire_bytes = msgpack.packb(mpack_dict, use_bin_type=True) |
| 162 | mpack_key = "sha256:" + hashlib.sha256(wire_bytes).hexdigest() |
| 163 | |
| 164 | presign_resp = await client.post( |
| 165 | f"/gabriel/{repo_slug}/push/mpack-presign", |
| 166 | content=msgpack.packb( |
| 167 | {"mpack_key": mpack_key, "size_bytes": len(wire_bytes)}, |
| 168 | use_bin_type=True, |
| 169 | ), |
| 170 | headers={"Content-Type": "application/x-msgpack"}, |
| 171 | ) |
| 172 | assert presign_resp.status_code == 200, presign_resp.text |
| 173 | upload_url = ( |
| 174 | presign_resp.json().get("upload_url") |
| 175 | or presign_resp.json().get("uploadUrl") |
| 176 | ) |
| 177 | |
| 178 | async with httpx.AsyncClient() as raw: |
| 179 | put = await raw.put(upload_url, content=wire_bytes) |
| 180 | assert put.status_code in (200, 204) |
| 181 | |
| 182 | n_commits = len(mpack_dict.get("commits") or []) |
| 183 | n_objects = len(mpack_dict.get("objects") or []) |
| 184 | |
| 185 | unpack_resp = await client.post( |
| 186 | f"/gabriel/{repo_slug}/push/unpack-mpack", |
| 187 | content=msgpack.packb( |
| 188 | { |
| 189 | "mpack_key": mpack_key, |
| 190 | "branch": branch, |
| 191 | "head": tip, |
| 192 | "commits_count": n_commits, |
| 193 | "objects_count": n_objects, |
| 194 | "force": force, |
| 195 | }, |
| 196 | use_bin_type=True, |
| 197 | ), |
| 198 | headers={"Content-Type": "application/x-msgpack"}, |
| 199 | ) |
| 200 | assert unpack_resp.status_code == expect_status, ( |
| 201 | f"Expected HTTP {expect_status}, got {unpack_resp.status_code}: {unpack_resp.text}" |
| 202 | ) |
| 203 | return unpack_resp.json() |
| 204 | |
| 205 | |
| 206 | async def _get_branch_head(db_session: AsyncSession, repo_slug: str, branch: str = "main") -> str | None: |
| 207 | """Return the current head_commit_id for the branch, or None.""" |
| 208 | repo_row = (await db_session.execute( |
| 209 | select(MusehubRepo).where(MusehubRepo.slug == repo_slug) |
| 210 | )).scalar_one_or_none() |
| 211 | if not repo_row: |
| 212 | return None |
| 213 | branch_row = (await db_session.execute( |
| 214 | select(MusehubBranch).where( |
| 215 | MusehubBranch.repo_id == repo_row.repo_id, |
| 216 | MusehubBranch.name == branch, |
| 217 | ) |
| 218 | )).scalar_one_or_none() |
| 219 | return branch_row.head_commit_id if branch_row else None |
| 220 | |
| 221 | |
| 222 | # --------------------------------------------------------------------------- |
| 223 | # FF-1 — normal fast-forward push succeeds and advances branch |
| 224 | # --------------------------------------------------------------------------- |
| 225 | |
| 226 | @pytest.mark.asyncio |
| 227 | async def test_ff1_fast_forward_push_advances_branch( |
| 228 | client: AsyncClient, repo: str, tmp_path: pathlib.Path, db_session: AsyncSession, |
| 229 | ) -> None: |
| 230 | """Remote at c1 → push c2 (parent=c1): 200, branch advances to c2.""" |
| 231 | root = _make_local_repo(tmp_path / "repo") |
| 232 | c1 = _add_commit(root, "c1") |
| 233 | c2 = _add_commit(root, "c2", parent_id=c1.commit_id) |
| 234 | write_branch_ref(root, "main", c2.commit_id) |
| 235 | |
| 236 | # First push: establish c1 on remote |
| 237 | await _upload_and_unpack(client, repo, root, c1.commit_id, have=[]) |
| 238 | |
| 239 | head_after_first = await _get_branch_head(db_session, repo) |
| 240 | assert head_after_first == c1.commit_id, "setup: branch should be at c1 after first push" |
| 241 | |
| 242 | # Second push: FF from c1 → c2 |
| 243 | await _upload_and_unpack(client, repo, root, c2.commit_id, have=[c1.commit_id]) |
| 244 | |
| 245 | head_after_second = await _get_branch_head(db_session, repo) |
| 246 | assert head_after_second == c2.commit_id, ( |
| 247 | f"FF push must advance branch to c2, got {head_after_second}" |
| 248 | ) |
| 249 | |
| 250 | |
| 251 | # --------------------------------------------------------------------------- |
| 252 | # FF-2 — non-FF push without force is rejected with 409 |
| 253 | # --------------------------------------------------------------------------- |
| 254 | |
| 255 | @pytest.mark.asyncio |
| 256 | async def test_ff2_non_ff_push_rejected_without_force( |
| 257 | client: AsyncClient, repo: str, tmp_path: pathlib.Path, db_session: AsyncSession, |
| 258 | ) -> None: |
| 259 | """Remote at c1 → push c2' (diverged, no force): 409, branch unchanged at c1.""" |
| 260 | root = _make_local_repo(tmp_path / "repo") |
| 261 | c1 = _add_commit(root, "c1") |
| 262 | # c2' is a genesis commit (different content, diverges from c1) |
| 263 | c2_diverged = _add_commit(root, "c2-diverged", parent_id=None) |
| 264 | |
| 265 | # Establish c1 on remote |
| 266 | await _upload_and_unpack(client, repo, root, c1.commit_id, have=[]) |
| 267 | head_after_first = await _get_branch_head(db_session, repo) |
| 268 | assert head_after_first == c1.commit_id |
| 269 | |
| 270 | # Try to push c2' (diverged) without force → must be rejected |
| 271 | await _upload_and_unpack( |
| 272 | client, repo, root, c2_diverged.commit_id, have=[], |
| 273 | force=False, expect_status=409, |
| 274 | ) |
| 275 | |
| 276 | head_after_rejected = await _get_branch_head(db_session, repo) |
| 277 | assert head_after_rejected == c1.commit_id, ( |
| 278 | f"Rejected non-FF push must not change branch, " |
| 279 | f"expected c1={c1.commit_id[:16]} got {str(head_after_rejected)[:16]}" |
| 280 | ) |
| 281 | |
| 282 | |
| 283 | # --------------------------------------------------------------------------- |
| 284 | # FF-3 — non-FF push with force=True is allowed |
| 285 | # --------------------------------------------------------------------------- |
| 286 | |
| 287 | @pytest.mark.asyncio |
| 288 | async def test_ff3_non_ff_push_allowed_with_force( |
| 289 | client: AsyncClient, repo: str, tmp_path: pathlib.Path, db_session: AsyncSession, |
| 290 | ) -> None: |
| 291 | """Remote at c1 → push c2' (diverged) with force=True: 200, branch at c2'.""" |
| 292 | root = _make_local_repo(tmp_path / "repo") |
| 293 | c1 = _add_commit(root, "c1") |
| 294 | c2_diverged = _add_commit(root, "c2-force", parent_id=None) |
| 295 | |
| 296 | # Establish c1 on remote |
| 297 | await _upload_and_unpack(client, repo, root, c1.commit_id, have=[]) |
| 298 | |
| 299 | # Force-push c2' (diverged) |
| 300 | await _upload_and_unpack( |
| 301 | client, repo, root, c2_diverged.commit_id, have=[], |
| 302 | force=True, expect_status=200, |
| 303 | ) |
| 304 | |
| 305 | head = await _get_branch_head(db_session, repo) |
| 306 | assert head == c2_diverged.commit_id, ( |
| 307 | f"Force push must advance branch to c2_diverged, got {str(head)[:16]}" |
| 308 | ) |
| 309 | |
| 310 | |
| 311 | # --------------------------------------------------------------------------- |
| 312 | # FF-4 — new branch (no prior head) always succeeds |
| 313 | # --------------------------------------------------------------------------- |
| 314 | |
| 315 | @pytest.mark.asyncio |
| 316 | async def test_ff4_new_branch_always_succeeds( |
| 317 | client: AsyncClient, repo: str, tmp_path: pathlib.Path, db_session: AsyncSession, |
| 318 | ) -> None: |
| 319 | """First push to a brand-new branch is always allowed (no prior head to protect).""" |
| 320 | root = _make_local_repo(tmp_path / "repo") |
| 321 | c1 = _add_commit(root, "genesis") |
| 322 | write_branch_ref(root, "main", c1.commit_id) |
| 323 | |
| 324 | await _upload_and_unpack(client, repo, root, c1.commit_id, have=[], expect_status=200) |
| 325 | |
| 326 | head = await _get_branch_head(db_session, repo) |
| 327 | assert head == c1.commit_id |
| 328 | |
| 329 | |
| 330 | # --------------------------------------------------------------------------- |
| 331 | # FF-5 — push where incoming_head == current_head is a no-op, always 200 |
| 332 | # --------------------------------------------------------------------------- |
| 333 | |
| 334 | @pytest.mark.asyncio |
| 335 | async def test_ff5_same_head_is_noop( |
| 336 | client: AsyncClient, repo: str, tmp_path: pathlib.Path, db_session: AsyncSession, |
| 337 | ) -> None: |
| 338 | """Pushing when local tip already equals remote head is a no-op: 200, branch unchanged.""" |
| 339 | root = _make_local_repo(tmp_path / "repo") |
| 340 | c1 = _add_commit(root, "c1-noop") |
| 341 | write_branch_ref(root, "main", c1.commit_id) |
| 342 | |
| 343 | await _upload_and_unpack(client, repo, root, c1.commit_id, have=[]) |
| 344 | |
| 345 | # Push again with the same head — empty mpack, same tip |
| 346 | await _upload_and_unpack( |
| 347 | client, repo, root, c1.commit_id, have=[c1.commit_id], expect_status=200, |
| 348 | ) |
| 349 | |
| 350 | head = await _get_branch_head(db_session, repo) |
| 351 | assert head == c1.commit_id |
File History
1 commit
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595
fix: typing audit — 0 violations, 0 untyped defs across all…
Sonnet 4.6
minor
⚠
22 days ago