Skip to content

Commit 9ebf8b6

Browse files
authored
Merge pull request #2227 from nkbeast/fix-config-backslash-continuation
fix: join backslash line continuations when reading config values
2 parents 62d1e2f + a15f791 commit 9ebf8b6

2 files changed

Lines changed: 100 additions & 1 deletion

File tree

git/config.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,45 @@ def string_decode(v: str) -> str:
467467

468468
# END string_decode
469469

470+
def is_line_continuation(value: str) -> bool:
471+
quoted = escaped = False
472+
for char in value:
473+
if escaped:
474+
escaped = False
475+
elif char == "\\":
476+
escaped = True
477+
elif char == '"':
478+
quoted = not quoted
479+
elif char in "#;" and not quoted:
480+
return False
481+
return escaped
482+
483+
def parse_value(value: str) -> str:
484+
parsed: List[str] = []
485+
whitespace: List[str] = []
486+
quoted = escaped = False
487+
escapes = {"b": "\b", "n": "\n", "t": "\t", '"': '"', "\\": "\\"}
488+
for char in value:
489+
if escaped:
490+
parsed.append(escapes.get(char, "\\" + char))
491+
escaped = False
492+
continue
493+
if char.isspace() and not quoted:
494+
if parsed:
495+
whitespace.append(char)
496+
continue
497+
if char in "#;" and not quoted:
498+
break
499+
parsed.extend(whitespace)
500+
whitespace.clear()
501+
if char == "\\":
502+
escaped = True
503+
elif char == '"':
504+
quoted = not quoted
505+
else:
506+
parsed.append(char)
507+
return "".join(parsed)
508+
470509
while True:
471510
# We assume to read binary!
472511
line = fp.readline().decode(defenc)
@@ -513,7 +552,29 @@ def string_decode(v: str) -> str:
513552

514553
if len(optval) < 2 or optval[0] != '"':
515554
# Does not open quoting.
516-
pass
555+
# A value ending in an odd number of backslashes
556+
# continues on the next line, exactly as git does: the
557+
# final backslash and the newline are removed and the
558+
# next line is appended before the complete value is
559+
# parsed. An even number means the last backslash is
560+
# escaped and the value ends there.
561+
continued = False
562+
while True:
563+
if not is_line_continuation(optval):
564+
break
565+
continuation = fp.readline()
566+
if not continuation:
567+
# Backslash at end of file: git drops it.
568+
optval = optval[:-1]
569+
break
570+
lineno = lineno + 1
571+
joined = continuation.decode(defenc)
572+
while joined.endswith("\n") or joined.endswith("\r"):
573+
joined = joined[:-1]
574+
optval = optval[:-1] + joined
575+
continued = True
576+
if continued:
577+
optval = parse_value(optval)
517578
elif optval[-1] != '"':
518579
# Opens quoting and does not close: appears to start multi-line quoting.
519580
is_multi_line = True

test/test_config.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,44 @@ def test_multi_line_config(self):
143143
)
144144
self.assertEqual(len(config.sections()), 23)
145145

146+
def test_backslash_line_continuation(self):
147+
"""An unquoted value ending in a backslash continues on the next line,
148+
exactly as git config parses it: the final backslash and the newline
149+
are removed before the complete logical value is parsed."""
150+
cases = [
151+
(b"[a]\n\tk = line1\\\n line2\n", "line1 line2"),
152+
(b"[a]\n\tk = one\\\n two\\\n three\n", "one two three"),
153+
(b"[a]\n\tk = one\\\n two \n", "one two"),
154+
(b"[a]\n\tk = one\\\n two ; ignored\n", "one two"),
155+
(b'[a]\n\tk = one\\\n "two"\n', "one two"),
156+
(b"[a]\n\tk = one\\\n two\\tthree\n", "one two\tthree"),
157+
(b"[a]\n\tk = val\\\\\n next\n", "val\\\\"),
158+
(b"[a]\n\tk = end\\\n", "end"),
159+
(b"[alias]\n\tco = checkout \\\n\t\t-v\n", "checkout \t\t-v"),
160+
]
161+
for content, expected in cases:
162+
config_file = io.BytesIO(content)
163+
config_file.name = "backslash_continuation.config"
164+
config = GitConfigParser(config_file)
165+
config.read()
166+
section = "alias" if b"[alias]" in content else "a"
167+
key = "co" if section == "alias" else "k"
168+
self.assertEqual(config.get_value(section, key), expected)
169+
170+
@with_rw_directory
171+
def test_comment_backslash_does_not_continue_value(self, rw_dir):
172+
config_path = osp.join(rw_dir, "config")
173+
with open(config_path, "wb") as config_file:
174+
config_file.write(b"[a]\n\tk = one\\\n two ; ignored \\\n\tx = two\n")
175+
176+
with GitConfigParser(config_path, read_only=False) as config:
177+
self.assertEqual(config.get_value("a", "k"), "one two")
178+
self.assertEqual(config.get_value("a", "x"), "two")
179+
config.set_value("a", "added", "three")
180+
181+
with GitConfigParser(config_path) as config:
182+
self.assertEqual(config.get_value("a", "x"), "two")
183+
146184
def test_config_value_with_trailing_new_line(self):
147185
config_content = b'[section-header]\nkey:"value\n"'
148186
config_file = io.BytesIO(config_content)

0 commit comments

Comments
 (0)