From 5a86a1dc74db1bd93f4a3aac4c7fef7323819002 Mon Sep 17 00:00:00 2001 From: Keerthana KT Date: Sat, 24 Oct 2026 18:08:17 +0530 Subject: [PATCH] fix(config): read an option on the same line as its section header `GitConfigParser._read` recognized a section header with the inherited `configparser` pattern `\[(?P
.+)\]`, matched against the stripped line, and discarded everything after the match. git ends a header at its first `]` outside a quoted subsection name and parses the rest of the line as an ordinary config line, so `[core] bare = true` sets `core.bare` in git and nothing in GitPython. `.gitmodules` is read with this parser and is controlled by whoever authored the superproject. For [submodule "lib"] path = lib url = https://good.example/lib [submodule "lib"] url = https://evil.example/lib `git config -f .gitmodules --list` shows both URLs, and `git submodule init` registers and later clones `https://evil.example/lib`, while `Repo.submodules` reports `https://good.example/lib`. A tool that uses GitPython to check which URLs a repository's submodules point at approves a different URL than the one git fetches. The greedy match also misread other lines: `[c] k = v ]` became a section named `c] k = v` with no options, and `[d] k = "multi\` continued on the next line produced an option named `line"` instead of `d.k = multi line`. The header now ends at the first unquoted `]`, with backslash escapes honored inside the quoted subsection name, and the text after it is fed back through the parse loop as a line of its own. That reuses the existing comment, option, continuation and header handling, so `[e] ; comment` adds nothing, `[b]flag` is an implicit boolean, and `[f] [g] k = v` sets `g.k`, all as git reads them. A header with unbalanced quotes keeps the previous reading up to the last `]`, and files whose headers stand alone on their lines parse exactly as before. `test_option_after_section_header_is_read_like_git` in `test/test_config.py` checks each case above against what `git config -f --list` prints on git 2.54.0. It fails before this change and passes after. `ruff check`, `ruff format --check` and `mypy` pass. In a full clone prepared with `init-tests-after-clone.sh`, the pytest suite passes except `test_diff_with_staged_file`, `test_empty_repo` and `test_rebasing`, which fail the same way without this change. --- git/config.py | 48 ++++++++++++++++++++++++++++++++++----------- test/test_config.py | 32 ++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/git/config.py b/git/config.py index 732d3658b..8ed4afbc2 100644 --- a/git/config.py +++ b/git/config.py @@ -578,17 +578,42 @@ def escapes_are_known(value: str) -> bool: index = value.find("\\", index + 2) return True + def split_section_header(line: str) -> Tuple[str, str]: + """Split a section header line into the header and the text after it. + + Like git, the header ends at the first ``]`` outside a quoted subsection + name. A header whose quotes are unbalanced keeps the previous reading up + to the last ``]``. + """ + quoted = escaped = False + for index, char in enumerate(line): + if escaped: + escaped = False + elif char == "\\" and quoted: + escaped = True + elif char == '"': + quoted = not quoted + elif char == "]" and not quoted and index > 1: + return line[1:index], line[index + 1 :] + return line[1 : line.rindex("]")], "" + + # Text after a section header on the same line, as in `[core] bare = true`. + # Git reads it as if it started a line of its own. + rest_of_line = "" while True: - # We assume to read binary! - raw_line = fp.readline() - if not raw_line: - break - if lineno == 0 and raw_line.startswith(b"\xef\xbb\xbf"): - # A UTF-8 BOM is not part of the content. git skips it, so a - # config file written by a Windows editor still parses. - raw_line = raw_line[3:] - line = raw_line.decode(defenc) - lineno = lineno + 1 + if rest_of_line: + line, rest_of_line = rest_of_line, "" + else: + # We assume to read binary! + raw_line = fp.readline() + if not raw_line: + break + if lineno == 0 and raw_line.startswith(b"\xef\xbb\xbf"): + # A UTF-8 BOM is not part of the content. git skips it, so a + # config file written by a Windows editor still parses. + raw_line = raw_line[3:] + line = raw_line.decode(defenc) + lineno = lineno + 1 # Comment or blank line? if line.strip() == "" or self.re_comment.match(line): continue @@ -599,7 +624,8 @@ def escapes_are_known(value: str) -> bool: # Is it a section header? mo = self.SECTCRE.match(line.strip()) if not is_multi_line and mo: - sectname: str = mo.group("header").strip() + header, rest_of_line = split_section_header(line.strip()) + sectname: str = header.strip() if sectname in self._sections: cursect = self._sections[sectname] elif sectname == cp.DEFAULTSECT: diff --git a/test/test_config.py b/test/test_config.py index 49b8535c7..59d8f8a67 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -462,6 +462,38 @@ def test_utf8_bom_is_skipped_like_git(self): config.read() self.assertIs(config.get_value("core", "bare"), True) + def test_option_after_section_header_is_read_like_git(self): + """git ends a section header at its first unquoted ``]`` and reads the rest + of the line as an option. Expectations are what + `git config -f --list` prints on git 2.54.0.""" + content = ( + b'[submodule "lib"]\n' + b"\tpath = lib\n" + b"\turl = https://good.example/lib\n" + b'[submodule "lib"] url = https://evil.example/lib\n' + b'[a "x]y"] k = v # comment\n' + b"[b]flag\n" + b"[c] k = v ]\n" + b'[d] k = "multi\\\n' + b' line"\n' + b"[e] ; comment\n" + b"[f] [g] k = v\n" + ) + config_file = io.BytesIO(content) + config_file.name = "header.config" + config = GitConfigParser(config_file) + config.read() + + self.assertEqual(config.get_value('submodule "lib"', "url"), "https://evil.example/lib") + self.assertEqual(config.get_value('a "x]y"', "k"), "v") + self.assertIs(config.getboolean("b", "flag"), True) + self.assertEqual(config.get_value("c", "k"), "v ]") + self.assertEqual(config.get_value("d", "k"), "multi line") + self.assertEqual(config.items("e"), []) + self.assertEqual(config.items("f"), []) + self.assertEqual(config.get_value("g", "k"), "v") + self.assertEqual(config.sections(), ['submodule "lib"', 'a "x]y"', "b", "c", "d", "e", "f", "g"]) + def test_config_value_with_trailing_new_line(self): config_content = b'[section-header]\nkey:"value\n"' config_file = io.BytesIO(config_content)