diff --git a/api/services/repository_rename.py b/api/services/repository_rename.py index 7065353..91d1f15 100644 --- a/api/services/repository_rename.py +++ b/api/services/repository_rename.py @@ -1052,12 +1052,28 @@ async def list_operations( return list(result.scalars().all()) +def _expected_head(operation: RepositoryRenameOperation) -> str: + """Head the Forge default branch must show at this point in the journal. + + The preflight baseline anchors every phase up to ``statehub-rebound``. + ``source-synced`` legitimately advances the head (the source-synchronization + revision and fix-consistency writeback are pushed from the fresh clone), so + once that phase is journalled its recorded Forge head is the anchor. + """ + journal, _ = _telemetry(operation) + synced = (journal.get("phases") or {}).get("source-synced") or {} + head = (synced.get("forge") or {}).get("head_commit") + return head or operation.expected_source_commit + + def _assert_snapshot( operation: RepositoryRenameOperation, snapshot: ForgeRepositorySnapshot, *, expected_name: str | None = None, + expected_head: str | None = None, ) -> None: + expected_head = expected_head or _expected_head(operation) if snapshot.repository_id != operation.expected_forge_repository_id: raise RenamePreconditionFailed( "Forge repository ID changed", @@ -1068,11 +1084,11 @@ def _assert_snapshot( ) if snapshot.default_branch != operation.expected_default_branch: raise RenamePreconditionFailed("Forge default branch changed") - if snapshot.head_commit != operation.expected_source_commit: + if snapshot.head_commit != expected_head: raise RenamePreconditionFailed( "Forge default-branch head moved", details={ - "expected": operation.expected_source_commit, + "expected": expected_head, "actual": snapshot.head_commit, }, ) @@ -1232,7 +1248,7 @@ async def verify_operation( if forge is not None: add("forge_coordinate", forge.name == expected_name, expected_name, forge.name) add("forge_repository_id", forge.repository_id == operation.expected_forge_repository_id, operation.expected_forge_repository_id, forge.repository_id) - add("source_commit", forge.head_commit == operation.expected_source_commit, operation.expected_source_commit, forge.head_commit) + add("source_commit", forge.head_commit == _expected_head(operation), _expected_head(operation), forge.head_commit) add("default_branch", forge.default_branch == operation.expected_default_branch, operation.expected_default_branch, forge.default_branch) add("projection_readable", forge.projection_readable and forge.projection_source_present, True, forge.projection_readable and forge.projection_source_present) expected_canonical = operation.old_slug if operation.phase in {"preflighted", "forge-renamed", "rolled-back"} else operation.new_slug @@ -1503,19 +1519,29 @@ async def apply_phase( }, ) clone_head = body.evidence.get("head_commit") - if clone_head != operation.expected_source_commit: - raise RenamePreconditionFailed( - "Fresh clone head does not match the rename baseline", - details={ - "expected": operation.expected_source_commit, - "actual": clone_head, - }, - ) forge = await _forge_at(gateway, operation, operation.new_slug) if forge is None: raise RenamePreconditionFailed("Renamed Forge repository is absent") - _assert_snapshot(operation, forge, expected_name=operation.new_slug) - evidence = {"forge": forge.as_dict(), "operator_evidence": body.evidence} + if clone_head != forge.head_commit: + raise RenamePreconditionFailed( + "Fresh clone head does not match the Forge default-branch head", + details={ + "expected": forge.head_commit, + "actual": clone_head, + "baseline": operation.expected_source_commit, + }, + ) + # The head may have advanced past the baseline through the + # source-synchronization revision; it becomes the new anchor. + _assert_snapshot( + operation, forge, expected_name=operation.new_slug, expected_head=clone_head + ) + evidence = { + "forge": forge.as_dict(), + "baseline_commit": operation.expected_source_commit, + "head_advanced": forge.head_commit != operation.expected_source_commit, + "operator_evidence": body.evidence, + } elif requested_phase == "consumers-verified": if not body.checks or not all(body.checks.values()): raise RenamePreconditionFailed( diff --git a/tests/test_repository_rename_api.py b/tests/test_repository_rename_api.py index 2597961..5988bae 100644 --- a/tests/test_repository_rename_api.py +++ b/tests/test_repository_rename_api.py @@ -735,6 +735,57 @@ async def test_redirected_forge_and_wrong_fresh_clone_identity_fail_closed_then_ assert replay["evidence"]["telemetry"]["retries"] == 1 +@pytest.mark.asyncio +async def test_source_sync_may_advance_forge_head_past_the_baseline(client, rename_setup): + repo, forge = rename_setup + preflight = await _preflight(client, repo["id"]) + operation, confirmation = await _operation(client, repo["id"], preflight) + await _phase(client, repo["id"], operation["id"], "forge-renamed", "preflighted", confirmation) + await _phase(client, repo["id"], operation["id"], "statehub-rebound", "forge-renamed", confirmation) + + # The source-synchronization revision is pushed from the fresh clone. + advanced = "b" * 40 + forge.head = advanced + stale = await client.post( + f"/repos/{repo['id']}/rename/operations/{operation['id']}/phases/source-synced", + json={ + "expected_phase": "statehub-rebound", + "confirmation": confirmation, + "evidence": {"fresh_clone": True, "forge_repository_id": 417, "head_commit": "a" * 40}, + }, + ) + assert stale.status_code == 412 + synced = await _phase( + client, + repo["id"], + operation["id"], + "source-synced", + "statehub-rebound", + confirmation, + evidence={"fresh_clone": True, "forge_repository_id": 417, "head_commit": advanced}, + ) + assert synced["phase"] == "source-synced" + recorded = synced["evidence"]["phases"]["source-synced"] + assert recorded["head_advanced"] is True + assert recorded["baseline_commit"] == "a" * 40 + + # Later phases anchor on the recorded head, so drift beyond it still fails. + verify = await client.get( + f"/repos/{repo['id']}/rename/operations/{operation['id']}/verify" + ) + assert verify.status_code == 200, verify.text + assert all( + check["ok"] for check in verify.json()["checks"] if check["name"] == "source_commit" + ) + forge.head = "c" * 40 + drifted = await client.get( + f"/repos/{repo['id']}/rename/operations/{operation['id']}/verify" + ) + assert any( + not check["ok"] for check in drifted.json()["checks"] if check["name"] == "source_commit" + ) + + @pytest.mark.asyncio async def test_rollback_recovers_when_old_statehub_slug_temporarily_unavailable( client, test_engine, rename_setup