From 452878f9ef8cc96c672cc2b5f491e3ac914764f4 Mon Sep 17 00:00:00 2001 From: Roshan Ramani Date: Thu, 17 Sep 2026 14:35:42 +0530 Subject: [PATCH 1/2] fix: strip inline config comments the way git does A `#` or `;` outside quotes starts a comment in git, with or without a space before it and whether or not the value is quoted. The parser only cut a `;` that was preceded by whitespace in an unquoted value, so `name = Alice # work` read back with the comment attached, and `k = "quoted" # after` was mistaken for an unterminated multi-line quote and returned `quoted" # after`. `strip_inline_comment` cuts the comment before the quote-structure branches, using the same quote- and escape-aware scan that `is_line_continuation` already uses, so all three branches see comment-free text. Escape handling is untouched. Co-authored-by: Claude Opus 5 --- git/config.py | 24 +++++++++++++++++++----- test/test_config.py | 23 +++++++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/git/config.py b/git/config.py index f54b4b97e..aa157fcde 100644 --- a/git/config.py +++ b/git/config.py @@ -511,6 +511,24 @@ def is_line_continuation(value: str) -> bool: return False return escaped + def strip_inline_comment(value: str) -> str: + """Cut an unquoted ``#`` or ``;`` comment, as git's ``parse_value`` does. + + Quoting and backslash escapes are honoured, so a ``#`` inside a quoted + value is literal and an unterminated quote swallows the rest of the line. + """ + quoted = escaped = False + for index, char in enumerate(value): + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + quoted = not quoted + elif char in "#;" and not quoted: + return value[:index] + return value + def parse_value(value: str) -> str: parsed: List[str] = [] whitespace: List[str] = [] @@ -575,11 +593,7 @@ def parse_value(value: str) -> str: optname, vi, optval = mo.group("option", "vi", "value") optname = self.optionxform(optname.rstrip()) - if vi in ("=", ":") and ";" in optval and not optval.strip().startswith('"'): - pos = optval.find(";") - if pos != -1 and optval[pos - 1].isspace(): - optval = optval[:pos] - optval = optval.strip() + optval = strip_inline_comment(optval).strip() if len(optval) < 2 or optval[0] != '"': # Does not open quoting. diff --git a/test/test_config.py b/test/test_config.py index 498b8879f..5f51d6a13 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -239,6 +239,29 @@ def test_multi_line_config(self): ) self.assertEqual(len(config.sections()), 23) + def test_inline_comments_are_stripped_like_git(self): + """A `#` or `;` outside quotes starts a comment, with or without a space + before it, and whether or not the value is quoted. Expectations are what + `git config -f --get a.k` prints on git 2.50.1.""" + cases = [ + (b"[a]\n\tk = value # comment\n", "value"), + (b"[a]\n\tk = value ; comment\n", "value"), + (b"[a]\n\tk = value#nospace\n", "value"), + (b"[a]\n\tk = value;nospace\n", "value"), + (b"[a]\n\tk = a # b ; c\n", "a"), + (b'[a]\n\tk = "quoted" # after\n', "quoted"), + # A comment character inside quotes is literal. + (b'[a]\n\tk = "has # inside"\n', "has # inside"), + (b'[a]\n\tk = "has ; inside"\n', "has ; inside"), + ] + for content, expected in cases: + config_file = io.BytesIO(content) + config_file.name = "inline_comment.config" + config = GitConfigParser(config_file) + config.read() + with self.subTest(content=content): + self.assertEqual(config.get_value("a", "k"), expected) + def test_backslash_line_continuation(self): """An unquoted value ending in a backslash continues on the next line, exactly as git config parses it: the final backslash and the newline From 258a884713302356358b5ad9f89dcd9b365b6b92 Mon Sep 17 00:00:00 2001 From: Byron Date: Sun, 20 Sep 2026 10:26:56 +0200 Subject: [PATCH 2/2] review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [P2] Preserve balanced quote state after removing comments — git/config.py:596-596 For valid Git syntax such as `k = "foo"bar # "note"` followed by `x = keep`, stripping produces `"foo"bar`, which the subsequent last-character check misclassifies as an open multiline quote. On `main`, `x` and later sections remained separate entries; this change absorbs them into `k`, and writing an unrelated setting removes them from the config. Classify the stripped value using its actual quote state and add a regression test covering subsequent settings. Assisted-by: GPT 6.0 Co-authored-by: GPT 6.0 --- git/config.py | 15 ++++++++------- test/test_config.py | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/git/config.py b/git/config.py index aa157fcde..7724406a7 100644 --- a/git/config.py +++ b/git/config.py @@ -511,8 +511,8 @@ def is_line_continuation(value: str) -> bool: return False return escaped - def strip_inline_comment(value: str) -> str: - """Cut an unquoted ``#`` or ``;`` comment, as git's ``parse_value`` does. + def strip_inline_comment(value: str) -> Tuple[str, bool]: + """Cut an unquoted ``#`` or ``;`` comment and report whether a quote is open. Quoting and backslash escapes are honoured, so a ``#`` inside a quoted value is literal and an unterminated quote swallows the rest of the line. @@ -526,8 +526,8 @@ def strip_inline_comment(value: str) -> str: elif char == '"': quoted = not quoted elif char in "#;" and not quoted: - return value[:index] - return value + return value[:index], False + return value, quoted def parse_value(value: str) -> str: parsed: List[str] = [] @@ -593,7 +593,8 @@ def parse_value(value: str) -> str: optname, vi, optval = mo.group("option", "vi", "value") optname = self.optionxform(optname.rstrip()) - optval = strip_inline_comment(optval).strip() + optval, quote_open = strip_inline_comment(optval) + optval = optval.strip() if len(optval) < 2 or optval[0] != '"': # Does not open quoting. @@ -620,12 +621,12 @@ def parse_value(value: str) -> str: continued = True if continued: optval = parse_value(optval) - elif optval[-1] != '"': + elif quote_open: # Opens quoting and does not close: appears to start multi-line quoting. is_multi_line = True optval = string_decode(optval[1:]) elif re.search(r'(?:^|[^\\])(?:\\\\)*"', optval[1:-1]): - # Preserve malformed values containing unescaped quotes. + # Preserve values containing additional unescaped quotes. pass else: # Opens and closes quoting. diff --git a/test/test_config.py b/test/test_config.py index 5f51d6a13..e00bde183 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -262,6 +262,28 @@ def test_inline_comments_are_stripped_like_git(self): with self.subTest(content=content): self.assertEqual(config.get_value("a", "k"), expected) + @with_rw_directory + def test_inline_comments_preserve_balanced_quotes_and_following_settings(self, rw_dir): + config_path = osp.join(rw_dir, "config") + values = (b'"foo"bar', b'"foo\\"bar"baz', b'"foo#;bar"baz') + for value in values: + for comment in (b' # "note"', b' ; "note"'): + with self.subTest(value=value, comment=comment): + with open(config_path, "wb") as config_file: + config_file.write(b"[a]\n\tk = " + value + comment + b"\n\tx = keep\n[b]\n\ty = stay\n") + + with GitConfigParser(config_path, read_only=False) as config: + self.assertEqual(config.get_value("a", "k"), value.decode(defenc)) + self.assertEqual(config.get_value("a", "x"), "keep") + self.assertEqual(config.get_value("b", "y"), "stay") + config.set_value("other", "value", "updated") + + with GitConfigParser(config_path) as config: + self.assertEqual(config.get_value("a", "k"), value.decode(defenc)) + self.assertEqual(config.get_value("a", "x"), "keep") + self.assertEqual(config.get_value("b", "y"), "stay") + self.assertEqual(config.get_value("other", "value"), "updated") + def test_backslash_line_continuation(self): """An unquoted value ending in a backslash continues on the next line, exactly as git config parses it: the final backslash and the newline