Skip to content

Commit 5a86a1d

Browse files
committed
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<header>.+)\]`, 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 <file> --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.
1 parent ccc4811 commit 5a86a1d

2 files changed

Lines changed: 69 additions & 11 deletions

File tree

‎git/config.py‎

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -578,17 +578,42 @@ def escapes_are_known(value: str) -> bool:
578578
index = value.find("\\", index + 2)
579579
return True
580580

581+
def split_section_header(line: str) -> Tuple[str, str]:
582+
"""Split a section header line into the header and the text after it.
583+
584+
Like git, the header ends at the first ``]`` outside a quoted subsection
585+
name. A header whose quotes are unbalanced keeps the previous reading up
586+
to the last ``]``.
587+
"""
588+
quoted = escaped = False
589+
for index, char in enumerate(line):
590+
if escaped:
591+
escaped = False
592+
elif char == "\\" and quoted:
593+
escaped = True
594+
elif char == '"':
595+
quoted = not quoted
596+
elif char == "]" and not quoted and index > 1:
597+
return line[1:index], line[index + 1 :]
598+
return line[1 : line.rindex("]")], ""
599+
600+
# Text after a section header on the same line, as in `[core] bare = true`.
601+
# Git reads it as if it started a line of its own.
602+
rest_of_line = ""
581603
while True:
582-
# We assume to read binary!
583-
raw_line = fp.readline()
584-
if not raw_line:
585-
break
586-
if lineno == 0 and raw_line.startswith(b"\xef\xbb\xbf"):
587-
# A UTF-8 BOM is not part of the content. git skips it, so a
588-
# config file written by a Windows editor still parses.
589-
raw_line = raw_line[3:]
590-
line = raw_line.decode(defenc)
591-
lineno = lineno + 1
604+
if rest_of_line:
605+
line, rest_of_line = rest_of_line, ""
606+
else:
607+
# We assume to read binary!
608+
raw_line = fp.readline()
609+
if not raw_line:
610+
break
611+
if lineno == 0 and raw_line.startswith(b"\xef\xbb\xbf"):
612+
# A UTF-8 BOM is not part of the content. git skips it, so a
613+
# config file written by a Windows editor still parses.
614+
raw_line = raw_line[3:]
615+
line = raw_line.decode(defenc)
616+
lineno = lineno + 1
592617
# Comment or blank line?
593618
if line.strip() == "" or self.re_comment.match(line):
594619
continue
@@ -599,7 +624,8 @@ def escapes_are_known(value: str) -> bool:
599624
# Is it a section header?
600625
mo = self.SECTCRE.match(line.strip())
601626
if not is_multi_line and mo:
602-
sectname: str = mo.group("header").strip()
627+
header, rest_of_line = split_section_header(line.strip())
628+
sectname: str = header.strip()
603629
if sectname in self._sections:
604630
cursect = self._sections[sectname]
605631
elif sectname == cp.DEFAULTSECT:

‎test/test_config.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,38 @@ def test_utf8_bom_is_skipped_like_git(self):
462462
config.read()
463463
self.assertIs(config.get_value("core", "bare"), True)
464464

465+
def test_option_after_section_header_is_read_like_git(self):
466+
"""git ends a section header at its first unquoted ``]`` and reads the rest
467+
of the line as an option. Expectations are what
468+
`git config -f <file> --list` prints on git 2.54.0."""
469+
content = (
470+
b'[submodule "lib"]\n'
471+
b"\tpath = lib\n"
472+
b"\turl = https://good.example/lib\n"
473+
b'[submodule "lib"] url = https://evil.example/lib\n'
474+
b'[a "x]y"] k = v # comment\n'
475+
b"[b]flag\n"
476+
b"[c] k = v ]\n"
477+
b'[d] k = "multi\\\n'
478+
b' line"\n'
479+
b"[e] ; comment\n"
480+
b"[f] [g] k = v\n"
481+
)
482+
config_file = io.BytesIO(content)
483+
config_file.name = "header.config"
484+
config = GitConfigParser(config_file)
485+
config.read()
486+
487+
self.assertEqual(config.get_value('submodule "lib"', "url"), "https://evil.example/lib")
488+
self.assertEqual(config.get_value('a "x]y"', "k"), "v")
489+
self.assertIs(config.getboolean("b", "flag"), True)
490+
self.assertEqual(config.get_value("c", "k"), "v ]")
491+
self.assertEqual(config.get_value("d", "k"), "multi line")
492+
self.assertEqual(config.items("e"), [])
493+
self.assertEqual(config.items("f"), [])
494+
self.assertEqual(config.get_value("g", "k"), "v")
495+
self.assertEqual(config.sections(), ['submodule "lib"', 'a "x]y"', "b", "c", "d", "e", "f", "g"])
496+
465497
def test_config_value_with_trailing_new_line(self):
466498
config_content = b'[section-header]\nkey:"value\n"'
467499
config_file = io.BytesIO(config_content)

0 commit comments

Comments
 (0)