mconf: Reload the options files if they have changed

This fixes issues where a new option is added, an option is removed, the
constraints of an option are changed, an option file is added where one
didn't previously exist, an option file is deleted, or it is renamed
between meson_options.txt and meson.options

There is one case that is known to not work, but it's probably a less
common case, which is setting options for an unconfigured subproject.
We could probably make that work in some cases, but I don't think it
makes sense to download a wrap during meson configure.
diff --git a/docs/markdown/snippets/meson_configure_options_changes.md b/docs/markdown/snippets/meson_configure_options_changes.md
new file mode 100644
index 0000000..c86792c
--- /dev/null
+++ b/docs/markdown/snippets/meson_configure_options_changes.md
@@ -0,0 +1,12 @@
+## Meson configure handles changes to options in more cases
+
+Meson configure now correctly handles updates to the options file without a full
+reconfigure. This allows making a change to the `meson.options` or
+`meson_options.txt` file without a reconfigure.
+
+For example, this now works:
+```sh
+meson setup builddir
+git pull
+meson configure builddir -Doption-added-by-pull=value
+```
diff --git a/mesonbuild/interpreter/interpreter.py b/mesonbuild/interpreter/interpreter.py
index 2c1ac89..730eddd 100644
--- a/mesonbuild/interpreter/interpreter.py
+++ b/mesonbuild/interpreter/interpreter.py
@@ -1190,8 +1190,8 @@
             option_file = old_option_file
         if os.path.exists(option_file):
             with open(option_file, 'rb') as f:
-                # We want fast, not cryptographically secure, this is just to see of
-                # the option file has changed
+                # We want fast  not cryptographically secure, this is just to
+                # see if the option file has changed
                 self.coredata.options_files[self.subproject] = (option_file, hashlib.sha1(f.read()).hexdigest())
             oi = optinterpreter.OptionInterpreter(self.subproject)
             oi.process(option_file)
diff --git a/mesonbuild/mconf.py b/mesonbuild/mconf.py
index 8c18eab..2cef24f 100644
--- a/mesonbuild/mconf.py
+++ b/mesonbuild/mconf.py
@@ -1,10 +1,11 @@
 # SPDX-License-Identifier: Apache-2.0
 # Copyright 2014-2016 The Meson development team
-# Copyright © 2023 Intel Corporation
+# Copyright © 2023-2024 Intel Corporation
 
 from __future__ import annotations
 
 import itertools
+import hashlib
 import shutil
 import os
 import textwrap
@@ -19,6 +20,7 @@
 from . import mlog
 from .ast import AstIDGenerator, IntrospectionInterpreter
 from .mesonlib import MachineChoice, OptionKey
+from .optinterpreter import OptionInterpreter
 
 if T.TYPE_CHECKING:
     from typing_extensions import Protocol
@@ -77,6 +79,33 @@
             self.source_dir = self.build.environment.get_source_dir()
             self.coredata = self.build.environment.coredata
             self.default_values_only = False
+
+            # if the option file has been updated, reload it
+            # This cannot handle options for a new subproject that has not yet
+            # been configured.
+            for sub, options in self.coredata.options_files.items():
+                if options is not None and os.path.exists(options[0]):
+                    opfile = options[0]
+                    with open(opfile, 'rb') as f:
+                        ophash = hashlib.sha1(f.read()).hexdigest()
+                        if ophash != options[1]:
+                            oi = OptionInterpreter(sub)
+                            oi.process(opfile)
+                            self.coredata.update_project_options(oi.options, sub)
+                            self.coredata.options_files[sub] = (opfile, ophash)
+                else:
+                    opfile = os.path.join(self.source_dir, 'meson.options')
+                    if not os.path.exists(opfile):
+                        opfile = os.path.join(self.source_dir, 'meson_options.txt')
+                    if os.path.exists(opfile):
+                        oi = OptionInterpreter(sub)
+                        oi.process(opfile)
+                        self.coredata.update_project_options(oi.options, sub)
+                        with open(opfile, 'rb') as f:
+                            ophash = hashlib.sha1(f.read()).hexdigest()
+                        self.coredata.options_files[sub] = (opfile, ophash)
+                    else:
+                        self.coredata.update_project_options({}, sub)
         elif os.path.isfile(os.path.join(self.build_dir, environment.build_filename)):
             # Make sure that log entries in other parts of meson don't interfere with the JSON output
             with mlog.no_logging():
diff --git a/unittests/platformagnostictests.py b/unittests/platformagnostictests.py
index 969cbd7..ba3f501 100644
--- a/unittests/platformagnostictests.py
+++ b/unittests/platformagnostictests.py
@@ -10,7 +10,7 @@
 import subprocess
 import textwrap
 import shutil
-from unittest import expectedFailure, skipIf, SkipTest
+from unittest import skipIf, SkipTest
 from pathlib import Path
 
 from .baseplatformtests import BasePlatformTests
@@ -318,7 +318,6 @@
             out = self.init(testdir, extra_args=['--wipe', f'-D{option}=1'], allow_fail=True)
             self.assertIn(f'ERROR: Unknown options: "{option}"', out)
 
-    @expectedFailure
     def test_configure_new_option(self) -> None:
         """Adding a new option without reconfiguring should work."""
         testdir = self.copy_srcdir(os.path.join(self.common_test_dir, '40 options'))
@@ -328,7 +327,6 @@
         self.setconf('-Dnew_option=true')
         self.assertEqual(self.getconf('new_option'), True)
 
-    @expectedFailure
     def test_configure_removed_option(self) -> None:
         """Removing an options without reconfiguring should still give an error."""
         testdir = self.copy_srcdir(os.path.join(self.common_test_dir, '40 options'))
@@ -344,7 +342,6 @@
             self.setconf('-Dneg_int_opt=0')
         self.assertIn('Unknown options: "neg_int_opt"', e.exception.stdout)
 
-    @expectedFailure
     def test_configure_option_changed_constraints(self) -> None:
         """Changing the constraints of an option without reconfiguring should work."""
         testdir = self.copy_srcdir(os.path.join(self.common_test_dir, '40 options'))
@@ -360,7 +357,6 @@
         self.setconf('-Dneg_int_opt=-10')
         self.assertEqual(self.getconf('neg_int_opt'), -10)
 
-    @expectedFailure
     def test_configure_meson_options_txt_to_meson_options(self) -> None:
         """Changing from a meson_options.txt to meson.options should still be detected."""
         testdir = self.copy_srcdir(os.path.join(self.common_test_dir, '40 options'))
@@ -377,7 +373,6 @@
         self.setconf('-Dneg_int_opt=-10')
         self.assertEqual(self.getconf('neg_int_opt'), -10)
 
-    @expectedFailure
     def test_configure_options_file_deleted(self) -> None:
         """Deleting all option files should make seting a project option an error."""
         testdir = self.copy_srcdir(os.path.join(self.common_test_dir, '40 options'))
@@ -387,7 +382,6 @@
             self.setconf('-Dneg_int_opt=0')
         self.assertIn('Unknown options: "neg_int_opt"', e.exception.stdout)
 
-    @expectedFailure
     def test_configure_options_file_added(self) -> None:
         """A new project option file should be detected."""
         testdir = self.copy_srcdir(os.path.join(self.common_test_dir, '1 trivial'))
@@ -397,7 +391,6 @@
         self.setconf('-Dnew_option=bar')
         self.assertEqual(self.getconf('new_option'), 'bar')
 
-    @expectedFailure
     def test_configure_options_file_added_old(self) -> None:
         """A new project option file should be detected."""
         testdir = self.copy_srcdir(os.path.join(self.common_test_dir, '1 trivial'))
@@ -407,7 +400,6 @@
         self.setconf('-Dnew_option=bar')
         self.assertEqual(self.getconf('new_option'), 'bar')
 
-    @expectedFailure
     def test_configure_new_option_subproject(self) -> None:
         """Adding a new option to a subproject without reconfiguring should work."""
         testdir = self.copy_srcdir(os.path.join(self.common_test_dir, '43 subproject options'))