Skip to content

Commit 2229668

Browse files
Byroncodex
andcommitted
fix: preserve implicit boolean config keys (#2237)
This was mostly a rubber-stamp, knowing the the whole implementation is quite a hack that is held together with ductape. Ideally, it will just work well enough at some point, to gain time for v4 to be made. <!-- agent --> GitConfigParser discarded keys written without an assignment. Reading such an option raised NoOptionError, and editing an unrelated setting silently removed it. Git treats a bare key as true but an explicitly empty value as false, so representing both as an empty string would lose their meaning. Store bare entries as None, following RawConfigParser's allow_no_value representation. Raw get/items access preserves the distinction, while get_value/get_values return an empty string as requested. Convert None to true and an empty string to false in getboolean, retaining the standard boolean spellings. Write None entries without an equals sign and exclude them from string validation and include-path expansion. The existing ordered multi-dict preserves repeated bare and assigned entries together. Update the regression that expected bare color.ui to disappear, and add a Git-backed read-modify-write test covering trailing whitespace, EOF without a newline, quoted and unquoted empty values, repeated keys, and a valueless non-path option in an include section. Both regressions failed before the fix. Compare Git's NUL-delimited listing before and after an unrelated edit and check the resulting repeated values with --type=bool --get-all. Git reference: checkout 1630431f326e15fcde608827b5ff38422528eb59, t/t1300-config.sh tests for novalue.variable and emptyvalue.variable, and parse.c:git_parse_maybe_bool_text. Runtime comparison used Git 2.50.1 (Apple Git-155). Assisted-by: GPT 6.0 Co-authored-by: GPT 6.0 <codex@openai.com>
1 parent a9913c7 commit 2229668

2 files changed

Lines changed: 91 additions & 14 deletions

File tree

git/config.py

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
from git.repo.base import Repo
4646

4747
T_ConfigParser = TypeVar("T_ConfigParser", bound="GitConfigParser")
48-
T_OMD_value = TypeVar("T_OMD_value", str, bytes, int, float, bool)
48+
T_OMD_value = TypeVar("T_OMD_value", str, bytes, int, float, bool, None)
4949

5050
if sys.version_info[:3] < (3, 7, 2):
5151
# typing.Ordereddict not added until Python 3.7.2.
@@ -291,6 +291,12 @@ class GitConfigParser(cp.RawConfigParser, metaclass=MetaParserBuilder):
291291
292292
:note:
293293
If used as a context manager, this will release the locked file.
294+
295+
:note:
296+
Options without a value are stored as ``None`` and written without ``=``.
297+
:meth:`get_value` and :meth:`get_values` return an empty string for them,
298+
while :meth:`getboolean` returns ``True``. An explicit empty value is
299+
stored as an empty string and reads as ``False`` with :meth:`getboolean`.
294300
"""
295301

296302
# { Configuration
@@ -348,7 +354,7 @@ def __init__(
348354
Reference to repository to use if ``[includeIf]`` sections are found in
349355
configuration files.
350356
"""
351-
cp.RawConfigParser.__init__(self, dict_type=_OMD)
357+
cp.RawConfigParser.__init__(self, dict_type=_OMD, allow_no_value=True)
352358
self._dict: Callable[..., _OMD]
353359
self._defaults: _OMD
354360
self._sections: _OMD
@@ -587,8 +593,12 @@ def parse_value(value: str) -> str:
587593
# Preserves multiple values for duplicate optnames.
588594
cursect.add(optname, optval)
589595
else:
590-
# Check if it's an option with no value - it's just ignored by git.
591-
if not self.OPTVALUEONLY.match(line):
596+
# A valueless option is an implicit boolean true, not an empty value.
597+
mo = self.OPTVALUEONLY.match(line)
598+
if mo:
599+
optname = self.optionxform(mo.group("option").rstrip())
600+
cursect.add(optname, None)
601+
else:
592602
if not e:
593603
e = cp.ParsingError(fpname)
594604
e.append(lineno, repr(line))
@@ -625,6 +635,7 @@ def _all_items(section: str) -> List[Tuple[str, str]]:
625635
for key, values in self._sections[section].items_all()
626636
if key != "__name__"
627637
for value in values
638+
if value is not None
628639
]
629640

630641
paths = []
@@ -760,13 +771,16 @@ def _write(self, fp: IO) -> None:
760771
def write_section(name: str, section_dict: _OMD) -> None:
761772
fp.write(("[%s]\n" % name).encode(defenc))
762773

763-
values: Sequence[str] # Runtime only gets str in tests, but should be whatever _OMD stores.
764-
v: str
774+
values: List[Any]
775+
v: Any
765776
for key, values in section_dict.items_all():
766777
if key == "__name__":
767778
continue
768779

769780
for v in values:
781+
if v is None:
782+
fp.write(("\t%s\n" % key).encode(defenc))
783+
continue
770784
value = self._value_to_string(v)
771785
if any(char in value for char in '\n\t\b\\"#;') or value[:1].isspace() or value[-1:].isspace():
772786
value = value.replace("\\", "\\\\").replace('"', '\\"')
@@ -783,11 +797,11 @@ def write_section(name: str, section_dict: _OMD) -> None:
783797
for name, value in self._sections.items():
784798
write_section(name, value)
785799

786-
def items(self, section_name: str) -> List[Tuple[str, str]]: # type: ignore[override]
800+
def items(self, section_name: str) -> List[Tuple[str, Union[str, None]]]: # type: ignore[override]
787801
""":return: list((option, value), ...) pairs of all items in the given section"""
788802
return [(k, v) for k, v in super().items(section_name) if k != "__name__"]
789803

790-
def items_all(self, section_name: str) -> List[Tuple[str, List[str]]]:
804+
def items_all(self, section_name: str) -> List[Tuple[str, List[Union[str, None]]]]:
791805
""":return: list((option, [values...]), ...) pairs of all items in the given section"""
792806
rv = _OMD(self._defaults)
793807

@@ -841,6 +855,8 @@ def write(self) -> None:
841855
for key, values in section.items_all():
842856
if key != "__name__":
843857
for raw_value in values:
858+
if raw_value is None:
859+
continue
844860
if "\r" in self._value_to_string(raw_value) or "\x00" in self._value_to_string(raw_value):
845861
raise ValueError("Git config values must not contain CR or NUL")
846862

@@ -877,7 +893,6 @@ def read_only(self) -> bool:
877893
""":return: ``True`` if this instance may change the configuration file"""
878894
return self._read_only
879895

880-
# FIXME: Figure out if default or return type can really include bool.
881896
def get_value(
882897
self,
883898
section: str,
@@ -894,7 +909,7 @@ def get_value(
894909
did not exist.
895910
896911
:return:
897-
A properly typed value, either int, float or string
912+
A properly typed value, either int, float, string or bool
898913
899914
:raise TypeError:
900915
In case the value could not be understood.
@@ -925,7 +940,7 @@ def get_values(
925940
in case the option did not exist.
926941
927942
:return:
928-
A list of properly typed values, either int, float or string
943+
A list of properly typed values, either int, float, string or bool
929944
930945
:raise TypeError:
931946
In case the value could not be understood.
@@ -941,7 +956,19 @@ def get_values(
941956

942957
return [self._string_to_value(valuestr) for valuestr in lst]
943958

944-
def _string_to_value(self, valuestr: str) -> Union[int, float, str, bool]:
959+
def _convert_to_boolean(self, value: Union[str, None]) -> bool:
960+
if value is None:
961+
return True
962+
if value == "":
963+
return False
964+
try:
965+
return self.BOOLEAN_STATES[value.lower()]
966+
except KeyError:
967+
raise ValueError("Not a boolean: %s" % value) from None
968+
969+
def _string_to_value(self, valuestr: Union[str, None]) -> Union[int, float, str, bool]:
970+
if valuestr is None:
971+
return ""
945972
types = (int, float)
946973
for numtype in types:
947974
try:

test/test_config.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -864,8 +864,58 @@ def test_empty_config_value(self):
864864

865865
assert cr.get_value("core", "filemode"), "Should read keys with values"
866866

867-
with self.assertRaises(cp.NoOptionError):
868-
cr.get_value("color", "ui")
867+
self.assertTrue(cr.has_option("color", "ui"))
868+
self.assertIsNone(cr.get("color", "ui"))
869+
self.assertEqual(cr.get_value("color", "ui"), "")
870+
self.assertIs(cr.getboolean("color", "ui"), True)
871+
872+
@with_rw_directory
873+
def test_implicit_boolean_round_trip(self, rw_dir):
874+
config_path = osp.join(rw_dir, "config")
875+
with open(config_path, "wb") as config_file:
876+
config_file.write(
877+
b"[include]\n"
878+
b"\toptional\n"
879+
b"[flag]\n"
880+
b"\timplicit\n"
881+
b"\ttrailing-space \n"
882+
b"\ttrailing-tab\t\n"
883+
b"\tempty =\n"
884+
b'\tquoted = ""\n'
885+
b"\tmultiple = false\n"
886+
b"\tmultiple\n"
887+
b"\tmultiple =\n"
888+
b"\tmultiple"
889+
)
890+
git_config = ["git", "config", "--file", config_path]
891+
original = subprocess.check_output(git_config + ["--null", "--list"])
892+
893+
with GitConfigParser(config_path, read_only=False) as config:
894+
for option in ("implicit", "trailing-space", "trailing-tab"):
895+
self.assertIsNone(config.get("flag", option))
896+
self.assertEqual(config.get_value("flag", option), "")
897+
self.assertIs(config.getboolean("flag", option), True)
898+
for option in ("empty", "quoted"):
899+
self.assertEqual(config.get("flag", option), "")
900+
self.assertEqual(config.get_value("flag", option), "")
901+
self.assertIs(config.getboolean("flag", option), False)
902+
self.assertEqual(config.get_values("flag", "multiple"), [False, "", "", ""])
903+
self.assertIsNone(dict(config.items("flag"))["multiple"])
904+
self.assertEqual(dict(config.items_all("flag"))["multiple"], ["false", None, "", None])
905+
config.set_value("other", "value", "updated")
906+
907+
self.assertEqual(
908+
subprocess.check_output(git_config + ["--null", "--list"]),
909+
original + b"other.value\nupdated\0",
910+
)
911+
self.assertEqual(
912+
subprocess.check_output(git_config + ["--type=bool", "--get-all", "flag.multiple"]),
913+
b"false\ntrue\nfalse\ntrue\n",
914+
)
915+
with GitConfigParser(config_path) as config:
916+
self.assertIs(config.getboolean("flag", "implicit"), True)
917+
self.assertIs(config.getboolean("flag", "empty"), False)
918+
self.assertEqual(dict(config.items_all("flag"))["multiple"], ["false", None, "", None])
869919

870920
def test_config_with_quotes(self):
871921
cr = GitConfigParser(fixture_path("git_config_with_quotes"), read_only=True)

0 commit comments

Comments
 (0)