From fb8a0bd9712a36748c6b70f718e5512569c26923 Mon Sep 17 00:00:00 2001 From: Byron Date: Mon, 14 Sep 2026 05:18:01 +0200 Subject: [PATCH] fix: match Git config names case-insensitively (#2240) Mostly a rubber-stamp, impl seems sane and tests seem to cover the important bits. GitConfigParser required exact section and option spelling, so valid Git configuration such as core.BigName could not be read as CORE.bigname. Differently cased sections and options also stayed separate, causing lookups to miss later values and writers to create duplicate settings. Index the ordered multi-dictionary by normalized names while retaining the first spelling in storage. Lowercase the section/option portion only; quoted subsection names remain case-sensitive. The shared mapping covers the inherited ConfigParser accessors, multivalue reads, and mutations without scanning all stored names. Case variants now merge in read order, and enumeration and write-back use the first spelling for each name. Normalize include section matching and remote discovery as well, while keeping include conditions and remote names case-sensitive. Add regressions for case variants, duplicate values, implicit booleans, quoted subsections, spelling-preserving writes, removal and renaming, included files, and remote discovery. The three new regression tests failed before the fix. Extend the existing setlast check to cover mixed case and clearing the name index. The behavior follows Documentation/config.adoc and the mixed-case and subsection tests in t/t1300-config.sh from the local Git reference at 1630431f326e15fcde608827b5ff38422528eb59. Regression comparisons with git config --get and --get-all used Git 2.50.1 (Apple Git-155). Assisted-by: GPT 6.0 Co-authored-by: GPT 6.0 --- git/config.py | 60 +++++++++++++++++++------- git/remote.py | 2 +- test/test_config.py | 103 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 146 insertions(+), 19 deletions(-) diff --git a/git/config.py b/git/config.py index cb130579a..f54b4b97e 100644 --- a/git/config.py +++ b/git/config.py @@ -64,7 +64,7 @@ CONFIG_LEVELS: ConfigLevels_Tup = ("system", "user", "global", "repository") """The configuration level of a configuration file.""" -CONDITIONAL_INCLUDE_REGEXP = re.compile(r"(?<=includeIf )\"(gitdir|gitdir/i|onbranch|hasconfig:remote\.\*\.url):(.+)\"") +CONDITIONAL_INCLUDE_REGEXP = re.compile(r"(?<=includeif )\"(gitdir|gitdir/i|onbranch|hasconfig:remote\.\*\.url):(.+)\"") """Section pattern to detect conditional includes. See: https://git-scm.com/docs/git-config#_conditional_includes @@ -203,41 +203,67 @@ def __exit__(self, exception_type: str, exception_value: str, traceback: str) -> self._config.__exit__(exception_type, exception_value, traceback) +def _normalize_name(name: str) -> str: + """Fold section and option names, leaving quoted subsections unchanged.""" + prefix, separator, subsection = name.partition('"') + return prefix.lower() + separator + subsection + + class _OMD(OrderedDict_OMD): - """Ordered multi-dict.""" + """Ordered multi-dict matching config names while retaining their first spelling.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self._keymap: Dict[str, str] = {} + super().__init__(*args, **kwargs) + + def _key(self, key: str) -> str: + stored = self._keymap.get(_normalize_name(key), key) + return stored if super().__contains__(stored) else key + + def __contains__(self, key: object) -> bool: + return isinstance(key, str) and super().__contains__(self._key(key)) + + def __delitem__(self, key: str) -> None: + super().__delitem__(self._key(key)) + del self._keymap[_normalize_name(key)] def __setitem__(self, key: str, value: _T) -> None: - super().__setitem__(key, [value]) + self.setall(key, [value]) + + def clear(self) -> None: + super().clear() + self._keymap.clear() def add(self, key: str, value: Any) -> None: if key not in self: - super().__setitem__(key, [value]) + self[key] = value return - super().__getitem__(key).append(value) + self.getall(key).append(value) def setall(self, key: str, values: List[_T]) -> None: + key = self._key(key) super().__setitem__(key, values) + self._keymap[_normalize_name(key)] = key def __getitem__(self, key: str) -> Any: - return super().__getitem__(key)[-1] + return super().__getitem__(self._key(key))[-1] def getlast(self, key: str) -> Any: - return super().__getitem__(key)[-1] + return self[key] def setlast(self, key: str, value: Any) -> None: if key not in self: - super().__setitem__(key, [value]) + self[key] = value return - prior = super().__getitem__(key) - prior[-1] = value + self.getall(key)[-1] = value def get(self, key: str, default: Union[_T, None] = None) -> Union[_T, None]: - return super().get(key, [default])[-1] + return super().get(self._key(key), [default])[-1] def getall(self, key: str) -> List[_T]: - return super().__getitem__(key) + return super().__getitem__(self._key(key)) def items(self) -> List[Tuple[str, _T]]: # type: ignore[override] """List of (key, last value for key).""" @@ -286,8 +312,9 @@ class GitConfigParser(cp.RawConfigParser, metaclass=MetaParserBuilder): other instances to write concurrently. :note: - The config is case-sensitive even when queried, hence section and option names - must match perfectly. + Section and option names are case-insensitive; quoted subsection names are + case-sensitive. Names retain their first spelling when enumerated or written. + Case variants are merged, preserving all values in the order they are read. :note: If used as a context manager, this will release the locked file. @@ -641,10 +668,11 @@ def _all_items(section: str) -> List[Tuple[str, str]]: paths = [] for section in self.sections(): - if section == "include": + normalized_section = _normalize_name(section) + if normalized_section == "include": paths += _all_items(section) - match = CONDITIONAL_INCLUDE_REGEXP.search(section) + match = CONDITIONAL_INCLUDE_REGEXP.search(normalized_section) if match is None or self._repo is None: continue diff --git a/git/remote.py b/git/remote.py index 2ddf11af0..fd58c8bd5 100644 --- a/git/remote.py +++ b/git/remote.py @@ -632,7 +632,7 @@ def exists(self) -> bool: def iter_items(cls, repo: "Repo", *args: Any, **kwargs: Any) -> Iterator["Remote"]: """:return: Iterator yielding :class:`Remote` objects of the given repository""" for section in repo.config_reader("repository").sections(): - if not section.startswith("remote "): + if not section.lower().startswith("remote "): continue lbound = section.find('"') rbound = section.rfind('"') diff --git a/test/test_config.py b/test/test_config.py index c0fa27f2c..498b8879f 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -13,7 +13,7 @@ import pytest -from git import GitConfigParser +from git import GitConfigParser, Repo from git.compat import defenc from git.config import _OMD, cp from git.util import cwd, rmfile @@ -103,6 +103,102 @@ def test_includes_order(self): except AssertionError as e: raise SkipTest("Known failure -- included values are not in effect right away") from e + @with_rw_directory + def test_case_insensitive_names(self, rw_dir): + config_path = osp.join(rw_dir, "config") + with open(config_path, "wb") as config_file: + config_file.write( + b"[core]\n\tBigName = 1\n" + b"[CoRe]\n\tbigname = 2\n\tFlag\n" + b'[REMOTE "Origin"]\n\tUrl = upper\n' + b'[remote "origin"]\n\tURL = lower\n' + ) + + with GitConfigParser(config_path) as config: + for section in ("core", "CORE", "CoRe"): + for option in ("BigName", "bigname", "BIGNAME"): + self.assertTrue(config.has_section(section)) + self.assertTrue(config.has_option(section, option)) + self.assertEqual(config.get(section, option), "2") + self.assertEqual(config.getint(section, option), 2) + self.assertEqual(config.get_value(section, option), 2) + self.assertEqual(config.get_values(section, option), [1, 2]) + self.assertIs(config.getboolean(section, "FLAG"), True) + self.assertEqual(config.sections(), ["core", 'REMOTE "Origin"', 'remote "origin"']) + self.assertEqual(config.items("CORE"), [("BigName", "2"), ("Flag", None)]) + self.assertEqual(config.items_all("CORE"), [("BigName", ["1", "2"]), ("Flag", [None])]) + self.assertIn("BigName", config.options("CORE")) + self.assertEqual(config.get('remote "Origin"', "URL"), "upper") + self.assertEqual(config.get('REMOTE "origin"', "url"), "lower") + self.assertFalse(config.has_section('remote "ORIGIN"')) + with self.assertRaises(cp.NoSectionError): + config.get('remote "ORIGIN"', "url") + + git_config = ["git", "config", "--file", config_path] + self.assertEqual(subprocess.check_output(git_config + ["--get-all", "CORE.BIGNAME"]), b"1\n2\n") + self.assertEqual(subprocess.check_output(git_config + ["--get", "remote.Origin.URL"]), b"upper\n") + self.assertEqual(subprocess.check_output(git_config + ["--get", "REMOTE.origin.url"]), b"lower\n") + + @with_rw_directory + def test_case_insensitive_writes_preserve_spelling(self, rw_dir): + config_path = osp.join(rw_dir, "config") + content = b'[CoRe]\n\tBigName = 1\n[REMOTE "Origin"]\n\tUrl = upper\n' + with open(config_path, "wb") as config_file: + config_file.write(content) + + with GitConfigParser(config_path, read_only=False) as config: + config.set_value("core", "bigname", 1) + with open(config_path, "rb") as config_file: + self.assertEqual(config_file.read(), content) + with self.assertRaises(cp.DuplicateSectionError): + config.add_section("CORE") + config.set("CORE", "BIGNAME", "3") + config.add_value("core", "bigname", 4) + config.set_value("CORE", "NewKey", "new") + self.assertEqual(config.items_all("core"), [("BigName", ["3", "4"]), ("NewKey", ["new"])]) + self.assertEqual(config.get_values("CORE", "BIGNAME"), [3, 4]) + self.assertTrue(config.remove_option("CORE", "NEWKEY")) + self.assertFalse(config.has_option("core", "newkey")) + config.set_value("core", "newkey", "again") + self.assertIn(("newkey", "again"), config.items("CORE")) + self.assertTrue(config.remove_option("CORE", "NEWKEY")) + config.rename_section('remote "Origin"', 'Remote "Other"') + self.assertEqual(config.get('REMOTE "Other"', "URL"), "upper") + self.assertTrue(config.remove_section('REMOTE "Other"')) + + with open(config_path, "rb") as config_file: + self.assertEqual(config_file.read(), b"[CoRe]\n\tBigName = 3\n\tBigName = 4\n") + with GitConfigParser(config_path) as config: + self.assertEqual(config.get_values("CORE", "bigname"), [3, 4]) + + @with_rw_directory + def test_case_insensitive_includes_and_remotes(self, rw_dir): + with Repo.init(rw_dir) as repo: + config_path = osp.join(repo.git_dir, "config") + with open(config_path, "ab") as config_file: + config_file.write( + b'[REMOTE "Origin"]\n\tURL = upper\n' + b'[Remote "origin"]\n\tUrl = lower\n' + b"[core]\n\tBigName = 1\n" + b"[INCLUDE]\n\tPaTh = included\n" + b'[INCLUDEIF "onbranch:*"]\n\tPATH = conditional\n' + b'[INCLUDEIF "ONBRANCH:*"]\n\tpath = wrong-case\n' + ) + for filename, content in ( + ("included", b"[CORE]\n\tBIGNAME = 2\n"), + ("conditional", b"[core]\n\tBranchName = 3\n"), + ("wrong-case", b"[core]\n\tbigname = 4\n"), + ): + with open(osp.join(repo.git_dir, filename), "wb") as config_file: + config_file.write(content) + + with repo.config_reader("repository") as config: + self.assertEqual(config.get_values("CORE", "bigname"), [1, 2]) + self.assertEqual(config.get_value("CORE", "branchname"), 3) + self.assertEqual([remote.name for remote in repo.remotes], ["Origin", "origin"]) + self.assertEqual(repo.remote("Origin").config_reader.get("url"), "upper") + self.assertEqual(repo.remote("origin").config_reader.get("URL"), "lower") + @with_rw_directory def test_lock_reentry(self, rw_dir): fpl = osp.join(rw_dir, "l") @@ -1119,6 +1215,9 @@ def test_setlast(self): omd.setlast("key", "value1") self.assertEqual(omd["key"], "value1") self.assertEqual(omd.getall("key"), ["value1"]) - omd.setlast("key", "value2") + omd.setlast("KEY", "value2") self.assertEqual(omd["key"], "value2") self.assertEqual(omd.getall("key"), ["value2"]) + omd.clear() + omd.setall("KEY", ["value3"]) + self.assertEqual(omd.items_all(), [("KEY", ["value3"])])