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"])])