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