Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion git/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,45 @@ def string_decode(v: str) -> str:

# END string_decode

def is_line_continuation(value: str) -> bool:
quoted = escaped = False
for char in value:
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == '"':
quoted = not quoted
elif char in "#;" and not quoted:
return False
return escaped

def parse_value(value: str) -> str:
parsed: List[str] = []
whitespace: List[str] = []
quoted = escaped = False
escapes = {"b": "\b", "n": "\n", "t": "\t", '"': '"', "\\": "\\"}
for char in value:
if escaped:
parsed.append(escapes.get(char, "\\" + char))
escaped = False
continue
if char.isspace() and not quoted:
if parsed:
whitespace.append(char)
continue
if char in "#;" and not quoted:
break
parsed.extend(whitespace)
whitespace.clear()
if char == "\\":
escaped = True
elif char == '"':
quoted = not quoted
else:
parsed.append(char)
return "".join(parsed)

while True:
# We assume to read binary!
line = fp.readline().decode(defenc)
Expand Down Expand Up @@ -513,7 +552,29 @@ def string_decode(v: str) -> str:

if len(optval) < 2 or optval[0] != '"':
# Does not open quoting.
pass
# A value ending in an odd number of backslashes
# continues on the next line, exactly as git does: the
# final backslash and the newline are removed and the
# next line is appended before the complete value is
# parsed. An even number means the last backslash is
# escaped and the value ends there.
continued = False
while True:
if not is_line_continuation(optval):
break
continuation = fp.readline()
if not continuation:
# Backslash at end of file: git drops it.
optval = optval[:-1]
break
lineno = lineno + 1
joined = continuation.decode(defenc)
while joined.endswith("\n") or joined.endswith("\r"):
joined = joined[:-1]
optval = optval[:-1] + joined
continued = True
if continued:
optval = parse_value(optval)
elif optval[-1] != '"':
# Opens quoting and does not close: appears to start multi-line quoting.
is_multi_line = True
Expand Down
38 changes: 38 additions & 0 deletions test/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,44 @@ def test_multi_line_config(self):
)
self.assertEqual(len(config.sections()), 23)

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
are removed before the complete logical value is parsed."""
cases = [
(b"[a]\n\tk = line1\\\n line2\n", "line1 line2"),
(b"[a]\n\tk = one\\\n two\\\n three\n", "one two three"),
(b"[a]\n\tk = one\\\n two \n", "one two"),
(b"[a]\n\tk = one\\\n two ; ignored\n", "one two"),
(b'[a]\n\tk = one\\\n "two"\n', "one two"),
(b"[a]\n\tk = one\\\n two\\tthree\n", "one two\tthree"),
(b"[a]\n\tk = val\\\\\n next\n", "val\\\\"),
(b"[a]\n\tk = end\\\n", "end"),
(b"[alias]\n\tco = checkout \\\n\t\t-v\n", "checkout \t\t-v"),
]
for content, expected in cases:
config_file = io.BytesIO(content)
config_file.name = "backslash_continuation.config"
config = GitConfigParser(config_file)
config.read()
section = "alias" if b"[alias]" in content else "a"
key = "co" if section == "alias" else "k"
self.assertEqual(config.get_value(section, key), expected)

@with_rw_directory
def test_comment_backslash_does_not_continue_value(self, rw_dir):
config_path = osp.join(rw_dir, "config")
with open(config_path, "wb") as config_file:
config_file.write(b"[a]\n\tk = one\\\n two ; ignored \\\n\tx = two\n")

with GitConfigParser(config_path, read_only=False) as config:
self.assertEqual(config.get_value("a", "k"), "one two")
self.assertEqual(config.get_value("a", "x"), "two")
config.set_value("a", "added", "three")

with GitConfigParser(config_path) as config:
self.assertEqual(config.get_value("a", "x"), "two")

def test_config_value_with_trailing_new_line(self):
config_content = b'[section-header]\nkey:"value\n"'
config_file = io.BytesIO(config_content)
Expand Down
Loading