diff --git a/src/poly/cli_commands/branch.py b/src/poly/cli_commands/branch.py index 89c881e1..1f4865f9 100644 --- a/src/poly/cli_commands/branch.py +++ b/src/poly/cli_commands/branch.py @@ -23,6 +23,11 @@ _BRANCH_MERGE_LONG_LINE_THRESHOLD = 800 +def _is_sequence_mismatch(errors: list[dict[str, Any]]) -> bool: + messages = (str(err.get("message", "")).lower() for err in errors) + return any("sequence mismatch" in m or "sequence_mismatch" in m for m in messages) + + def _branch_merge_conflict_file_key(path: list[str]) -> str: """Group field-level API conflicts by parent path (resource-ish key).""" if not path: @@ -1182,6 +1187,11 @@ def _report_merge_success() -> None: plain("\n[red]Errors:[/red]") for err in errors: error(f"- {err['path']}: {err['message']}") + if _is_sequence_mismatch(errors): + warning( + "The branch changed while merging (e.g. a draft deployment finished). " + "Re-run the merge." + ) enriched = enrich_branch_merge_conflicts(conflicts) if conflicts else [] display_conflict = [ @@ -1503,6 +1513,11 @@ def branch_sync( plain("\n[red]Errors:[/red]") for err in errors: error(f"- {err['path']}: {err['message']}") + if _is_sequence_mismatch(errors): + warning( + "The branch changed while syncing (e.g. a draft deployment finished). " + "Re-run the sync." + ) enriched = enrich_branch_merge_conflicts(conflicts) if conflicts else [] display_conflict = [ diff --git a/src/poly/handlers/sdk.py b/src/poly/handlers/sdk.py index 6a275be4..cb3b5d08 100644 --- a/src/poly/handlers/sdk.py +++ b/src/poly/handlers/sdk.py @@ -307,8 +307,10 @@ def merge_branch( ) """ try: + # Fetch fresh; the cached sequence can lag after a write. + self._last_known_sequence = self.fetch_last_known_sequence_number() payload = { - "expectedBranchLastKnownSequence": self.get_last_known_sequence() or 0, + "expectedBranchLastKnownSequence": self._last_known_sequence, } if deployment_message: @@ -412,8 +414,9 @@ def sync_branch( ) """ try: + self._last_known_sequence = self.fetch_last_known_sequence_number() payload = { - "expectedBranchSequence": self.get_last_known_sequence() or 0, + "expectedBranchSequence": self._last_known_sequence, } if conflict_resolutions is not None: diff --git a/src/poly/handlers/sync_client.py b/src/poly/handlers/sync_client.py index 0c53e3f7..f27c4d6f 100644 --- a/src/poly/handlers/sync_client.py +++ b/src/poly/handlers/sync_client.py @@ -347,7 +347,7 @@ def merge_branch( logger.error( f"Failed to merge branch '{self.sdk.branch_id}' into its parent branch: {e}" ) - return False, [], [] + return False, [], [{"path": [], "message": str(e)}] if result.get("hasConflicts", False) or result.get("errors", []): logger.info( @@ -390,7 +390,7 @@ def sync_branch( ) except SourcererAPIError as e: logger.error(f"Failed to sync branch '{self.sdk.branch_id}': {e}") - return False, [], [] + return False, [], [{"path": [], "message": str(e)}] if result.get("hasConflicts", False) or result.get("errors", []): logger.info( diff --git a/src/poly/tests/api/sdk_test.py b/src/poly/tests/api/sdk_test.py index 3be2135c..bd6f7dd5 100644 --- a/src/poly/tests/api/sdk_test.py +++ b/src/poly/tests/api/sdk_test.py @@ -231,5 +231,60 @@ def test_request_failure_raises_sourcerer_error(self, mock_post, _mock_key): sdk.send_command_batch() +class MergeBranch(unittest.TestCase): + """Tests for SourcererSDK.merge_branch sequence handling.""" + + def _sdk_with_sequence(self, server_sequence: int): + sdk = build_sdk() + session = MagicMock() + session.get.return_value = make_mock_response( + 200, json_body={"lastKnownSequence": str(server_sequence)} + ) + session.post.return_value = make_mock_response( + 200, json_body={"sequence": str(server_sequence + 1), "message": "ok"} + ) + sdk._session = session + return sdk, session + + def test_merge_fetches_fresh_sequence_over_stale_cache(self): + """The merge payload carries the server's sequence, not the cached one.""" + sdk, session = self._sdk_with_sequence(server_sequence=1751) + # Stale cache, e.g. from a projection read that lagged the event store. + sdk._last_known_sequence = 1750 + + sdk.merge_branch(deployment_message="msg") + + session.get.assert_called_once_with( + "https://sourcerer.test/accounts/acc-1/projects/proj-1/branches/branch-1/sequence" + ) + payload = session.post.call_args.kwargs["json"] + self.assertEqual(payload["expectedBranchLastKnownSequence"], 1751) + self.assertEqual(sdk._last_known_sequence, 1751) + + def test_sync_fetches_fresh_sequence_over_stale_cache(self): + """sync_branch refreshes the sequence the same way.""" + sdk, session = self._sdk_with_sequence(server_sequence=1751) + sdk._last_known_sequence = 1750 + + sdk.sync_branch() + + payload = session.post.call_args.kwargs["json"] + self.assertEqual(payload["expectedBranchSequence"], 1751) + + def test_sequence_mismatch_error_raises_sourcerer_error(self): + """A non-conflict 400 (e.g. SEQUENCE_MISMATCH) raises SourcererAPIError.""" + sdk, session = self._sdk_with_sequence(server_sequence=1750) + session.post.return_value = make_mock_response( + 400, + json_body={ + "error": "sequence mismatch, received 1750 but expected 1751", + "error_code": "SEQUENCE_MISMATCH", + }, + ) + + with self.assertRaises(SourcererAPIError): + sdk.merge_branch(deployment_message="msg") + + if __name__ == "__main__": unittest.main() diff --git a/src/poly/tests/api/sync_client_test.py b/src/poly/tests/api/sync_client_test.py index 3e76c8d5..00edb2f8 100644 --- a/src/poly/tests/api/sync_client_test.py +++ b/src/poly/tests/api/sync_client_test.py @@ -215,5 +215,41 @@ def test_api_error_returns_false(self): self.assertFalse(result) +class MergeBranch(unittest.TestCase): + """Tests for SyncClientHandler.merge_branch / sync_branch error reporting.""" + + def _handler_on_branch(self): + handler = build_handler() + handler._sdk.branch_id = "branch-1" + handler._sdk.fetch_branches.return_value = {"branches": [{"branchId": "branch-1"}]} + return handler + + def test_merge_api_error_is_returned_in_errors(self): + """A SourcererAPIError (e.g. SEQUENCE_MISMATCH) is surfaced to the caller.""" + handler = self._handler_on_branch() + handler._sdk.merge_branch.side_effect = SourcererAPIError( + "API Error 400: sequence mismatch, received 1750 but expected 1751" + ) + + success, conflicts, errors = handler.merge_branch(message="msg") + + self.assertFalse(success) + self.assertEqual(conflicts, []) + self.assertEqual(len(errors), 1) + self.assertEqual(errors[0]["path"], []) + self.assertIn("sequence mismatch", errors[0]["message"]) + + def test_sync_api_error_is_returned_in_errors(self): + """sync_branch reports API errors the same way as merge_branch.""" + handler = self._handler_on_branch() + handler._sdk.sync_branch.side_effect = SourcererAPIError("API Error 400: boom") + + success, conflicts, errors = handler.sync_branch() + + self.assertFalse(success) + self.assertEqual(conflicts, []) + self.assertEqual(errors, [{"path": [], "message": "API Error 400: boom"}]) + + if __name__ == "__main__": unittest.main()