Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/dulwich/config.py: 61%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# config.py - Reading and writing Git config files
2# Copyright (C) 2011-2013 Jelmer Vernooij <jelmer@jelmer.uk>
3#
4# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
5# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
6# General Public License as published by the Free Software Foundation; version 2.0
7# or (at your option) any later version. You can redistribute it and/or
8# modify it under the terms of either of these two licenses.
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15#
16# You should have received a copy of the licenses; if not, see
17# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
18# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
19# License, Version 2.0.
20#
22"""Reading and writing Git configuration files.
24Todo:
25 * preserve formatting when updating configuration files
26"""
28import logging
29import os
30import re
31import sys
32from collections.abc import (
33 Callable,
34 ItemsView,
35 Iterable,
36 Iterator,
37 KeysView,
38 Mapping,
39 MutableMapping,
40 ValuesView,
41)
42from contextlib import suppress
43from pathlib import Path
44from typing import (
45 IO,
46 Generic,
47 TypeVar,
48 Union,
49 overload,
50)
52from .file import GitFile, _GitFile
54ConfigKey = str | bytes | tuple[str | bytes, ...]
55ConfigValue = str | bytes | bool | int
57logger = logging.getLogger(__name__)
59# Type for file opener callback
60FileOpener = Callable[[str | os.PathLike[str]], IO[bytes]]
62# Type for includeIf condition matcher
63# Takes the condition value (e.g., "main" for onbranch:main) and returns bool
64ConditionMatcher = Callable[[str], bool]
66# Security limits for include files
67MAX_INCLUDE_FILE_SIZE = 1024 * 1024 # 1MB max for included config files
68DEFAULT_MAX_INCLUDE_DEPTH = 10 # Maximum recursion depth for includes
71def _match_gitdir_pattern(
72 path: bytes, pattern: bytes, ignorecase: bool = False
73) -> bool:
74 """Simple gitdir pattern matching for includeIf conditions.
76 This handles the basic gitdir patterns used in includeIf directives.
77 """
78 # Convert to strings for easier manipulation
79 path_str = path.decode("utf-8", errors="replace")
80 pattern_str = pattern.decode("utf-8", errors="replace")
82 # Normalize paths to use forward slashes for consistent matching
83 path_str = path_str.replace("\\", "/")
84 pattern_str = pattern_str.replace("\\", "/")
86 if ignorecase:
87 path_str = path_str.lower()
88 pattern_str = pattern_str.lower()
90 # Handle the common cases for gitdir patterns
91 if pattern_str.startswith("**/") and pattern_str.endswith("/**"):
92 # Pattern like **/dirname/** should match any path containing dirname
93 dirname = pattern_str[3:-3] # Remove **/ and /**
94 # Check if path contains the directory name as a path component
95 return ("/" + dirname + "/") in path_str or path_str.endswith("/" + dirname)
96 elif pattern_str.startswith("**/"):
97 # Pattern like **/filename
98 suffix = pattern_str[3:] # Remove **/
99 return suffix in path_str or path_str.endswith("/" + suffix)
100 elif pattern_str.endswith("/**"):
101 # Pattern like /path/to/dir/** should match /path/to/dir and any subdirectory
102 base_pattern = pattern_str[:-3] # Remove /**
103 return path_str == base_pattern or path_str.startswith(base_pattern + "/")
104 elif "**" in pattern_str:
105 # Handle patterns with ** in the middle
106 parts = pattern_str.split("**")
107 if len(parts) == 2:
108 prefix, suffix = parts
109 # Path must start with prefix and end with suffix (if any)
110 if prefix and not path_str.startswith(prefix):
111 return False
112 if suffix and not path_str.endswith(suffix):
113 return False
114 return True
116 # Direct match or simple glob pattern
117 if "*" in pattern_str or "?" in pattern_str or "[" in pattern_str:
118 import fnmatch
120 return fnmatch.fnmatch(path_str, pattern_str)
121 else:
122 return path_str == pattern_str
125def match_glob_pattern(value: str, pattern: str) -> bool:
126 r"""Match a value against a glob pattern.
128 Supports simple glob patterns like ``*`` and ``**``.
130 Raises:
131 ValueError: If the pattern is invalid
132 """
133 # Convert glob pattern to regex
134 pattern_escaped = re.escape(pattern)
135 # Replace escaped \*\* with .* (match anything)
136 pattern_escaped = pattern_escaped.replace(r"\*\*", ".*")
137 # Replace escaped \* with [^/]* (match anything except /)
138 pattern_escaped = pattern_escaped.replace(r"\*", "[^/]*")
139 # Anchor the pattern
140 pattern_regex = f"^{pattern_escaped}$"
142 try:
143 return bool(re.match(pattern_regex, value))
144 except re.error as e:
145 raise ValueError(f"Invalid glob pattern {pattern!r}: {e}")
148def lower_key(key: ConfigKey) -> ConfigKey:
149 """Convert a config key to lowercase, preserving subsection case.
151 Args:
152 key: Configuration key (str, bytes, or tuple)
154 Returns:
155 Key with section names lowercased, subsection names preserved
157 Raises:
158 TypeError: If key is not str, bytes, or tuple
159 """
160 if isinstance(key, (bytes, str)):
161 return key.lower()
163 if isinstance(key, tuple):
164 # For config sections, only lowercase the section name (first element)
165 # but preserve the case of subsection names (remaining elements)
166 if len(key) > 0:
167 first = key[0]
168 assert isinstance(first, (bytes, str))
169 return (first.lower(), *key[1:])
170 return key
172 raise TypeError(key)
175K = TypeVar("K", bound=ConfigKey) # Key type must be ConfigKey
176V = TypeVar("V") # Value type
177_T = TypeVar("_T") # For get() default parameter
180class CaseInsensitiveOrderedMultiDict(MutableMapping[K, V], Generic[K, V]):
181 """A case-insensitive ordered dictionary that can store multiple values per key.
183 This class maintains the order of insertions and allows multiple values
184 for the same key. Keys are compared case-insensitively.
185 """
187 def __init__(self, default_factory: Callable[[], V] | None = None) -> None:
188 """Initialize a CaseInsensitiveOrderedMultiDict.
190 Args:
191 default_factory: Optional factory function for default values
192 """
193 self._real: list[tuple[K, V]] = []
194 self._keyed: dict[ConfigKey, V] = {}
195 self._default_factory = default_factory
197 @classmethod
198 def make(
199 cls,
200 dict_in: Union[MutableMapping[K, V], "CaseInsensitiveOrderedMultiDict[K, V]"]
201 | None = None,
202 default_factory: Callable[[], V] | None = None,
203 ) -> "CaseInsensitiveOrderedMultiDict[K, V]":
204 """Create a CaseInsensitiveOrderedMultiDict from an existing mapping.
206 Args:
207 dict_in: Optional mapping to initialize from
208 default_factory: Optional factory function for default values
210 Returns:
211 New CaseInsensitiveOrderedMultiDict instance
213 Raises:
214 TypeError: If dict_in is not a mapping or None
215 """
216 if isinstance(dict_in, cls):
217 return dict_in
219 out = cls(default_factory=default_factory)
221 if dict_in is None:
222 return out
224 if not isinstance(dict_in, MutableMapping):
225 raise TypeError
227 for key, value in dict_in.items():
228 out[key] = value
230 return out
232 def __len__(self) -> int:
233 """Return the number of unique keys in the dictionary."""
234 return len(self._keyed)
236 def keys(self) -> KeysView[K]:
237 """Return a view of the dictionary's keys."""
238 # Return a view of the original keys (not lowercased)
239 # We need to deduplicate since _real can have duplicates
240 seen = set()
241 unique_keys = []
242 for k, _ in self._real:
243 lower = lower_key(k)
244 if lower not in seen:
245 seen.add(lower)
246 unique_keys.append(k)
247 from collections.abc import KeysView as ABCKeysView
249 class UniqueKeysView(ABCKeysView[K]):
250 def __init__(self, keys: list[K]):
251 self._keys = keys
253 def __contains__(self, key: object) -> bool:
254 return key in self._keys
256 def __iter__(self) -> Iterator[K]:
257 return iter(self._keys)
259 def __len__(self) -> int:
260 return len(self._keys)
262 return UniqueKeysView(unique_keys)
264 def items(self) -> ItemsView[K, V]:
265 """Return a view of the dictionary's (key, value) pairs in insertion order."""
267 # Return a view that iterates over the real list to preserve order
268 class OrderedItemsView(ItemsView[K, V]):
269 """Items view that preserves insertion order."""
271 def __init__(self, mapping: CaseInsensitiveOrderedMultiDict[K, V]):
272 self._mapping = mapping
274 def __iter__(self) -> Iterator[tuple[K, V]]:
275 return iter(self._mapping._real)
277 def __len__(self) -> int:
278 return len(self._mapping._real)
280 def __contains__(self, item: object) -> bool:
281 if not isinstance(item, tuple) or len(item) != 2:
282 return False
283 key, value = item
284 return any(k == key and v == value for k, v in self._mapping._real)
286 return OrderedItemsView(self)
288 def __iter__(self) -> Iterator[K]:
289 """Iterate over the dictionary's keys."""
290 # Return iterator over original keys (not lowercased), deduplicated
291 seen = set()
292 for k, _ in self._real:
293 lower = lower_key(k)
294 if lower not in seen:
295 seen.add(lower)
296 yield k
298 def values(self) -> ValuesView[V]:
299 """Return a view of the dictionary's values."""
300 return self._keyed.values()
302 def __setitem__(self, key: K, value: V) -> None:
303 """Set a value for a key, appending to existing values."""
304 self._real.append((key, value))
305 self._keyed[lower_key(key)] = value
307 def set(self, key: K, value: V) -> None:
308 """Set a value for a key, replacing all existing values.
310 Args:
311 key: The key to set
312 value: The value to set
313 """
314 # This method replaces all existing values for the key
315 lower = lower_key(key)
316 self._real = [(k, v) for k, v in self._real if lower_key(k) != lower]
317 self._real.append((key, value))
318 self._keyed[lower] = value
320 def __delitem__(self, key: K) -> None:
321 """Delete all values for a key.
323 Raises:
324 KeyError: If the key is not found
325 """
326 lower_k = lower_key(key)
327 del self._keyed[lower_k]
328 for i, (actual, unused_value) in reversed(list(enumerate(self._real))):
329 if lower_key(actual) == lower_k:
330 del self._real[i]
332 def __getitem__(self, item: K) -> V:
333 """Get the last value for a key.
335 Raises:
336 KeyError: If the key is not found
337 """
338 return self._keyed[lower_key(item)]
340 def get(self, key: K, /, default: V | _T | None = None) -> V | _T | None: # type: ignore[override]
341 """Get the last value for a key, or a default if not found.
343 Args:
344 key: The key to look up
345 default: Default value to return if key not found
347 Returns:
348 The value for the key, or default/default_factory result if not found
349 """
350 try:
351 return self[key]
352 except KeyError:
353 if default is not None:
354 return default
355 elif self._default_factory is not None:
356 return self._default_factory()
357 else:
358 return None
360 def get_all(self, key: K) -> Iterator[V]:
361 """Get all values for a key in insertion order.
363 Args:
364 key: The key to look up
366 Returns:
367 Iterator of all values for the key
368 """
369 lowered_key = lower_key(key)
370 for actual, value in self._real:
371 if lower_key(actual) == lowered_key:
372 yield value
374 def setdefault(self, key: K, default: V | None = None) -> V:
375 """Get value for key, setting it to default if not present.
377 Args:
378 key: The key to look up
379 default: Default value to set if key not found
381 Returns:
382 The existing value or the newly set default
384 Raises:
385 KeyError: If key not found and no default or default_factory
386 """
387 try:
388 return self[key]
389 except KeyError:
390 if default is not None:
391 self[key] = default
392 return default
393 elif self._default_factory is not None:
394 value = self._default_factory()
395 self[key] = value
396 return value
397 else:
398 raise
401Name = bytes
402NameLike = bytes | str
403Section = tuple[bytes, ...]
404SectionLike = bytes | str | tuple[bytes | str, ...]
405Value = bytes
406ValueLike = bytes | str
409class Config:
410 """A Git configuration."""
412 def get(self, section: SectionLike, name: NameLike) -> Value:
413 """Retrieve the contents of a configuration setting.
415 Args:
416 section: Tuple with section name and optional subsection name
417 name: Variable name
418 Returns:
419 Contents of the setting
420 Raises:
421 KeyError: if the value is not set
422 """
423 raise NotImplementedError(self.get)
425 def get_multivar(self, section: SectionLike, name: NameLike) -> Iterator[Value]:
426 """Retrieve the contents of a multivar configuration setting.
428 Args:
429 section: Tuple with section name and optional subsection namee
430 name: Variable name
431 Returns:
432 Contents of the setting as iterable
433 Raises:
434 KeyError: if the value is not set
435 """
436 raise NotImplementedError(self.get_multivar)
438 @overload
439 def get_boolean(
440 self, section: SectionLike, name: NameLike, default: bool
441 ) -> bool: ...
443 @overload
444 def get_boolean(self, section: SectionLike, name: NameLike) -> bool | None: ...
446 def get_boolean(
447 self, section: SectionLike, name: NameLike, default: bool | None = None
448 ) -> bool | None:
449 """Retrieve a configuration setting as boolean.
451 Args:
452 section: Tuple with section name and optional subsection name
453 name: Name of the setting, including section and possible
454 subsection.
455 default: Default value if setting is not found
457 Returns:
458 Contents of the setting
459 """
460 try:
461 value = self.get(section, name)
462 except KeyError:
463 return default
464 if value.lower() == b"true":
465 return True
466 elif value.lower() == b"false":
467 return False
468 raise ValueError(f"not a valid boolean string: {value!r}")
470 def set(
471 self, section: SectionLike, name: NameLike, value: ValueLike | bool
472 ) -> None:
473 """Set a configuration value.
475 Args:
476 section: Tuple with section name and optional subsection namee
477 name: Name of the configuration value, including section
478 and optional subsection
479 value: value of the setting
480 """
481 raise NotImplementedError(self.set)
483 def items(self, section: SectionLike) -> Iterator[tuple[Name, Value]]:
484 """Iterate over the configuration pairs for a specific section.
486 Args:
487 section: Tuple with section name and optional subsection namee
488 Returns:
489 Iterator over (name, value) pairs
490 """
491 raise NotImplementedError(self.items)
493 def sections(self) -> Iterator[Section]:
494 """Iterate over the sections.
496 Returns: Iterator over section tuples
497 """
498 raise NotImplementedError(self.sections)
500 def has_section(self, name: Section) -> bool:
501 """Check if a specified section exists.
503 Args:
504 name: Name of section to check for
505 Returns:
506 boolean indicating whether the section exists
507 """
508 return name in self.sections()
511class ConfigDict(Config):
512 """Git configuration stored in a dictionary."""
514 def __init__(
515 self,
516 values: MutableMapping[Section, CaseInsensitiveOrderedMultiDict[Name, Value]]
517 | None = None,
518 encoding: str | None = None,
519 ) -> None:
520 """Create a new ConfigDict."""
521 if encoding is None:
522 encoding = sys.getdefaultencoding()
523 self.encoding = encoding
524 self._values: CaseInsensitiveOrderedMultiDict[
525 Section, CaseInsensitiveOrderedMultiDict[Name, Value]
526 ] = CaseInsensitiveOrderedMultiDict.make(
527 values, default_factory=CaseInsensitiveOrderedMultiDict
528 )
530 def __repr__(self) -> str:
531 """Return string representation of ConfigDict."""
532 return f"{self.__class__.__name__}({self._values!r})"
534 def __eq__(self, other: object) -> bool:
535 """Check equality with another ConfigDict."""
536 return isinstance(other, self.__class__) and other._values == self._values
538 def __getitem__(self, key: Section) -> CaseInsensitiveOrderedMultiDict[Name, Value]:
539 """Get configuration values for a section.
541 Raises:
542 KeyError: If section not found
543 """
544 return self._values.__getitem__(key)
546 def __setitem__(
547 self, key: Section, value: CaseInsensitiveOrderedMultiDict[Name, Value]
548 ) -> None:
549 """Set configuration values for a section."""
550 return self._values.__setitem__(key, value)
552 def __delitem__(self, key: Section) -> None:
553 """Delete a configuration section.
555 Raises:
556 KeyError: If section not found
557 """
558 return self._values.__delitem__(key)
560 def __iter__(self) -> Iterator[Section]:
561 """Iterate over configuration sections."""
562 return self._values.__iter__()
564 def __len__(self) -> int:
565 """Return the number of sections."""
566 return self._values.__len__()
568 def keys(self) -> KeysView[Section]:
569 """Return a view of section names."""
570 return self._values.keys()
572 @classmethod
573 def _parse_setting(cls, name: str) -> tuple[str, str | None, str]:
574 parts = name.split(".")
575 if len(parts) == 3:
576 return (parts[0], parts[1], parts[2])
577 else:
578 return (parts[0], None, parts[1])
580 def _check_section_and_name(
581 self, section: SectionLike, name: NameLike
582 ) -> tuple[Section, Name]:
583 if not isinstance(section, tuple):
584 section = (section,)
586 checked_section = tuple(
587 [
588 subsection.encode(self.encoding)
589 if not isinstance(subsection, bytes)
590 else subsection
591 for subsection in section
592 ]
593 )
595 if not isinstance(name, bytes):
596 name = name.encode(self.encoding)
598 return checked_section, name
600 def get_multivar(self, section: SectionLike, name: NameLike) -> Iterator[Value]:
601 """Get multiple values for a configuration setting.
603 Args:
604 section: Section name
605 name: Setting name
607 Returns:
608 Iterator of configuration values
609 """
610 section, name = self._check_section_and_name(section, name)
612 if len(section) > 1:
613 try:
614 return self._values[section].get_all(name)
615 except KeyError:
616 pass
618 return self._values[(section[0],)].get_all(name)
620 def get(
621 self,
622 section: SectionLike,
623 name: NameLike,
624 ) -> Value:
625 """Get a configuration value.
627 Args:
628 section: Section name
629 name: Setting name
631 Returns:
632 Configuration value
634 Raises:
635 KeyError: if the value is not set
636 """
637 section, name = self._check_section_and_name(section, name)
639 if len(section) > 1:
640 try:
641 return self._values[section][name]
642 except KeyError:
643 pass
645 return self._values[(section[0],)][name]
647 def set(
648 self,
649 section: SectionLike,
650 name: NameLike,
651 value: ValueLike | bool,
652 ) -> None:
653 """Set a configuration value.
655 Args:
656 section: Section name
657 name: Setting name
658 value: Configuration value
659 """
660 section, name = self._check_section_and_name(section, name)
662 if isinstance(value, bool):
663 value = b"true" if value else b"false"
665 if not isinstance(value, bytes):
666 value = value.encode(self.encoding)
668 section_dict = self._values.setdefault(section)
669 if hasattr(section_dict, "set"):
670 section_dict.set(name, value)
671 else:
672 section_dict[name] = value
674 def add(
675 self,
676 section: SectionLike,
677 name: NameLike,
678 value: ValueLike | bool,
679 ) -> None:
680 """Add a value to a configuration setting, creating a multivar if needed."""
681 section, name = self._check_section_and_name(section, name)
683 if isinstance(value, bool):
684 value = b"true" if value else b"false"
686 if not isinstance(value, bytes):
687 value = value.encode(self.encoding)
689 self._values.setdefault(section)[name] = value
691 def items(self, section: SectionLike) -> Iterator[tuple[Name, Value]]:
692 """Get items in a section."""
693 section_bytes, _ = self._check_section_and_name(section, b"")
694 section_dict = self._values.get(section_bytes)
695 if section_dict is not None:
696 return iter(section_dict.items())
697 return iter([])
699 def sections(self) -> Iterator[Section]:
700 """Get all sections."""
701 return iter(self._values.keys())
704def _format_string(value: bytes) -> bytes:
705 if (
706 value.startswith((b" ", b"\t"))
707 or value.endswith((b" ", b"\t"))
708 or b"#" in value
709 ):
710 return b'"' + _escape_value(value) + b'"'
711 else:
712 return _escape_value(value)
715_ESCAPE_TABLE = {
716 ord(b"\\"): ord(b"\\"),
717 ord(b'"'): ord(b'"'),
718 ord(b"n"): ord(b"\n"),
719 ord(b"t"): ord(b"\t"),
720 ord(b"b"): ord(b"\b"),
721}
722_COMMENT_CHARS = [ord(b"#"), ord(b";")]
723_WHITESPACE_CHARS = [ord(b"\t"), ord(b" ")]
726def _parse_string(value: bytes) -> bytes:
727 value_array = bytearray(value.strip())
728 ret = bytearray()
729 whitespace = bytearray()
730 in_quotes = False
731 i = 0
732 while i < len(value_array):
733 c = value_array[i]
734 if c == ord(b"\\"):
735 i += 1
736 if i >= len(value_array):
737 # Backslash at end of string - treat as literal backslash
738 if whitespace:
739 ret.extend(whitespace)
740 whitespace = bytearray()
741 ret.append(ord(b"\\"))
742 else:
743 try:
744 v = _ESCAPE_TABLE[value_array[i]]
745 if whitespace:
746 ret.extend(whitespace)
747 whitespace = bytearray()
748 ret.append(v)
749 except KeyError:
750 # Unknown escape sequence - treat backslash as literal and process next char normally
751 if whitespace:
752 ret.extend(whitespace)
753 whitespace = bytearray()
754 ret.append(ord(b"\\"))
755 i -= 1 # Reprocess the character after the backslash
756 elif c == ord(b'"'):
757 in_quotes = not in_quotes
758 elif c in _COMMENT_CHARS and not in_quotes:
759 # the rest of the line is a comment
760 break
761 elif c in _WHITESPACE_CHARS:
762 whitespace.append(c)
763 else:
764 if whitespace:
765 ret.extend(whitespace)
766 whitespace = bytearray()
767 ret.append(c)
768 i += 1
770 if in_quotes:
771 raise ValueError("missing end quote")
773 return bytes(ret)
776def _escape_value(value: bytes) -> bytes:
777 """Escape a value."""
778 value = value.replace(b"\\", b"\\\\")
779 value = value.replace(b"\r", b"\\r")
780 value = value.replace(b"\n", b"\\n")
781 value = value.replace(b"\t", b"\\t")
782 value = value.replace(b'"', b'\\"')
783 return value
786def _check_variable_name(name: bytes) -> bool:
787 for i in range(len(name)):
788 c = name[i : i + 1]
789 if not c.isalnum() and c != b"-":
790 return False
791 return True
794def _check_section_name(name: bytes) -> bool:
795 for i in range(len(name)):
796 c = name[i : i + 1]
797 if not c.isalnum() and c not in (b"-", b"."):
798 return False
799 return True
802def _strip_comments(line: bytes) -> bytes:
803 comment_bytes = {ord(b"#"), ord(b";")}
804 quote = ord(b'"')
805 string_open = False
806 # Normalize line to bytearray for simple 2/3 compatibility
807 for i, character in enumerate(bytearray(line)):
808 # Comment characters outside balanced quotes denote comment start
809 if character == quote:
810 string_open = not string_open
811 elif not string_open and character in comment_bytes:
812 return line[:i]
813 return line
816def _is_line_continuation(value: bytes) -> bool:
817 """Check if a value ends with a line continuation backslash.
819 A line continuation occurs when a line ends with a backslash that is:
820 1. Not escaped (not preceded by another backslash)
821 2. Not within quotes
823 Args:
824 value: The value to check
826 Returns:
827 True if the value ends with a line continuation backslash
828 """
829 if not value.endswith((b"\\\n", b"\\\r\n")):
830 return False
832 # Remove only the newline characters, keep the content including the backslash
833 if value.endswith(b"\\\r\n"):
834 content = value[:-2] # Remove \r\n, keep the \
835 else:
836 content = value[:-1] # Remove \n, keep the \
838 if not content.endswith(b"\\"):
839 return False
841 # Count consecutive backslashes at the end
842 backslash_count = 0
843 for i in range(len(content) - 1, -1, -1):
844 if content[i : i + 1] == b"\\":
845 backslash_count += 1
846 else:
847 break
849 # If we have an odd number of backslashes, the last one is a line continuation
850 # If we have an even number, they are all escaped and there's no continuation
851 return backslash_count % 2 == 1
854def _parse_section_header_line(line: bytes) -> tuple[Section, bytes]:
855 # Parse section header ("[bla]")
856 line = _strip_comments(line).rstrip()
857 in_quotes = False
858 escaped = False
859 for i, c in enumerate(line):
860 if escaped:
861 escaped = False
862 continue
863 if c == ord(b'"'):
864 in_quotes = not in_quotes
865 if c == ord(b"\\"):
866 escaped = True
867 if c == ord(b"]") and not in_quotes:
868 last = i
869 break
870 else:
871 raise ValueError("expected trailing ]")
872 pts = line[1:last].split(b" ", 1)
873 line = line[last + 1 :]
874 section: Section
875 if len(pts) == 2:
876 # Handle subsections - Git allows more complex syntax for certain sections like includeIf
877 if pts[1][:1] == b'"' and pts[1][-1:] == b'"':
878 # Standard quoted subsection
879 pts[1] = pts[1][1:-1]
880 elif pts[0] == b"includeIf":
881 # Special handling for includeIf sections which can have complex conditions
882 # Git allows these without strict quote validation
883 pts[1] = pts[1].strip()
884 if pts[1][:1] == b'"' and pts[1][-1:] == b'"':
885 pts[1] = pts[1][1:-1]
886 else:
887 # Other sections must have quoted subsections
888 raise ValueError(f"Invalid subsection {pts[1]!r}")
889 if not _check_section_name(pts[0]):
890 raise ValueError(f"invalid section name {pts[0]!r}")
891 section = (pts[0], pts[1])
892 else:
893 if not _check_section_name(pts[0]):
894 raise ValueError(f"invalid section name {pts[0]!r}")
895 pts = pts[0].split(b".", 1)
896 if len(pts) == 2:
897 section = (pts[0], pts[1])
898 else:
899 section = (pts[0],)
900 return section, line
903class ConfigFile(ConfigDict):
904 """A Git configuration file, like .git/config or ~/.gitconfig."""
906 def __init__(
907 self,
908 values: MutableMapping[Section, CaseInsensitiveOrderedMultiDict[Name, Value]]
909 | None = None,
910 encoding: str | None = None,
911 ) -> None:
912 """Initialize a ConfigFile.
914 Args:
915 values: Optional mapping of configuration values
916 encoding: Optional encoding for the file (defaults to system encoding)
917 """
918 super().__init__(values=values, encoding=encoding)
919 self.path: str | None = None
920 self._included_paths: set[str] = set() # Track included files to prevent cycles
922 @classmethod
923 def from_file(
924 cls,
925 f: IO[bytes],
926 *,
927 config_dir: str | None = None,
928 included_paths: set[str] | None = None,
929 include_depth: int = 0,
930 max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH,
931 file_opener: FileOpener | None = None,
932 condition_matchers: Mapping[str, ConditionMatcher] | None = None,
933 ) -> "ConfigFile":
934 """Read configuration from a file-like object.
936 Args:
937 f: File-like object to read from
938 config_dir: Directory containing the config file (for relative includes)
939 included_paths: Set of already included paths (to prevent cycles)
940 include_depth: Current include depth (to prevent infinite recursion)
941 max_include_depth: Maximum allowed include depth
942 file_opener: Optional callback to open included files
943 condition_matchers: Optional dict of condition matchers for includeIf
944 """
945 if include_depth > max_include_depth:
946 # Prevent excessive recursion
947 raise ValueError(f"Maximum include depth ({max_include_depth}) exceeded")
949 ret = cls()
950 if included_paths is not None:
951 ret._included_paths = included_paths.copy()
953 section: Section | None = None
954 setting = None
955 continuation = None
956 for lineno, line in enumerate(f.readlines()):
957 if lineno == 0 and line.startswith(b"\xef\xbb\xbf"):
958 line = line[3:]
959 line = line.lstrip()
960 if setting is None:
961 if len(line) > 0 and line[:1] == b"[":
962 section, line = _parse_section_header_line(line)
963 ret._values.setdefault(section)
964 if _strip_comments(line).strip() == b"":
965 continue
966 if section is None:
967 raise ValueError(f"setting {line!r} without section")
968 try:
969 setting, value = line.split(b"=", 1)
970 except ValueError:
971 setting = line
972 value = b"true"
973 setting = setting.strip()
974 if not _check_variable_name(setting):
975 raise ValueError(f"invalid variable name {setting!r}")
976 if _is_line_continuation(value):
977 if value.endswith(b"\\\r\n"):
978 continuation = value[:-3]
979 else:
980 continuation = value[:-2]
981 else:
982 continuation = None
983 value = _parse_string(value)
984 ret._values[section][setting] = value
986 # Process include/includeIf directives
987 ret._handle_include_directive(
988 section,
989 setting,
990 value,
991 config_dir=config_dir,
992 include_depth=include_depth,
993 max_include_depth=max_include_depth,
994 file_opener=file_opener,
995 condition_matchers=condition_matchers,
996 )
998 setting = None
999 else: # continuation line
1000 assert continuation is not None
1001 if _is_line_continuation(line):
1002 if line.endswith(b"\\\r\n"):
1003 continuation += line[:-3]
1004 else:
1005 continuation += line[:-2]
1006 else:
1007 continuation += line
1008 value = _parse_string(continuation)
1009 assert section is not None # Already checked above
1010 ret._values[section][setting] = value
1012 # Process include/includeIf directives
1013 ret._handle_include_directive(
1014 section,
1015 setting,
1016 value,
1017 config_dir=config_dir,
1018 include_depth=include_depth,
1019 max_include_depth=max_include_depth,
1020 file_opener=file_opener,
1021 condition_matchers=condition_matchers,
1022 )
1024 continuation = None
1025 setting = None
1026 return ret
1028 def _handle_include_directive(
1029 self,
1030 section: Section | None,
1031 setting: bytes,
1032 value: bytes,
1033 *,
1034 config_dir: str | None,
1035 include_depth: int,
1036 max_include_depth: int,
1037 file_opener: FileOpener | None,
1038 condition_matchers: Mapping[str, ConditionMatcher] | None,
1039 ) -> None:
1040 """Handle include/includeIf directives during config parsing."""
1041 if (
1042 section is not None
1043 and setting == b"path"
1044 and (
1045 section[0].lower() == b"include"
1046 or (len(section) > 1 and section[0].lower() == b"includeif")
1047 )
1048 ):
1049 self._process_include(
1050 section,
1051 value,
1052 config_dir=config_dir,
1053 include_depth=include_depth,
1054 max_include_depth=max_include_depth,
1055 file_opener=file_opener,
1056 condition_matchers=condition_matchers,
1057 )
1059 def _process_include(
1060 self,
1061 section: Section,
1062 path_value: bytes,
1063 *,
1064 config_dir: str | None,
1065 include_depth: int,
1066 max_include_depth: int,
1067 file_opener: FileOpener | None,
1068 condition_matchers: Mapping[str, ConditionMatcher] | None,
1069 ) -> None:
1070 """Process an include or includeIf directive."""
1071 path_str = path_value.decode(self.encoding, errors="replace")
1073 # Handle includeIf conditions
1074 if len(section) > 1 and section[0].lower() == b"includeif":
1075 condition = section[1].decode(self.encoding, errors="replace")
1076 if not self._evaluate_includeif_condition(
1077 condition, config_dir, condition_matchers
1078 ):
1079 return
1081 # Resolve the include path
1082 include_path = self._resolve_include_path(path_str, config_dir)
1083 if not include_path:
1084 return
1086 # Check for circular includes
1087 try:
1088 abs_path = str(Path(include_path).resolve())
1089 except (OSError, ValueError) as e:
1090 # Invalid path - log and skip
1091 logger.debug("Invalid include path %r: %s", include_path, e)
1092 return
1093 if abs_path in self._included_paths:
1094 return
1096 # Load and merge the included file
1097 try:
1098 # Use provided file opener or default to GitFile
1099 opener: FileOpener
1100 if file_opener is None:
1102 def opener(path: str | os.PathLike[str]) -> IO[bytes]:
1103 return GitFile(path, "rb")
1104 else:
1105 opener = file_opener
1107 f = opener(include_path)
1108 except (OSError, ValueError) as e:
1109 # Git silently ignores missing or unreadable include files
1110 # Log for debugging purposes
1111 logger.debug("Invalid include path %r: %s", include_path, e)
1112 else:
1113 with f as included_file:
1114 # Track this path to prevent cycles
1115 self._included_paths.add(abs_path)
1117 # Parse the included file
1118 included_config = ConfigFile.from_file(
1119 included_file,
1120 config_dir=os.path.dirname(include_path),
1121 included_paths=self._included_paths,
1122 include_depth=include_depth + 1,
1123 max_include_depth=max_include_depth,
1124 file_opener=file_opener,
1125 condition_matchers=condition_matchers,
1126 )
1128 # Merge the included configuration
1129 self._merge_config(included_config)
1131 def _merge_config(self, other: "ConfigFile") -> None:
1132 """Merge another config file into this one."""
1133 for section, values in other._values.items():
1134 if section not in self._values:
1135 self._values[section] = CaseInsensitiveOrderedMultiDict()
1136 for key, value in values.items():
1137 self._values[section][key] = value
1139 def _resolve_include_path(self, path: str, config_dir: str | None) -> str | None:
1140 """Resolve an include path to an absolute path."""
1141 # Expand ~ to home directory
1142 path = os.path.expanduser(path)
1144 # If path is relative and we have a config directory, make it relative to that
1145 if not os.path.isabs(path) and config_dir:
1146 path = os.path.join(config_dir, path)
1148 return path
1150 def _evaluate_includeif_condition(
1151 self,
1152 condition: str,
1153 config_dir: str | None = None,
1154 condition_matchers: Mapping[str, ConditionMatcher] | None = None,
1155 ) -> bool:
1156 """Evaluate an includeIf condition."""
1157 # Try custom matchers first if provided
1158 if condition_matchers:
1159 for prefix, matcher in condition_matchers.items():
1160 if condition.startswith(prefix):
1161 return matcher(condition[len(prefix) :])
1163 # Fall back to built-in matchers
1164 if condition.startswith("hasconfig:"):
1165 return self._evaluate_hasconfig_condition(condition[10:])
1166 else:
1167 # Unknown condition type - log and ignore (Git behavior)
1168 logger.debug("Unknown includeIf condition: %r", condition)
1169 return False
1171 def _evaluate_hasconfig_condition(self, condition: str) -> bool:
1172 """Evaluate a hasconfig condition.
1174 Format: hasconfig:config.key:pattern
1175 Example: hasconfig:remote.*.url:ssh://org-*@github.com/**
1176 """
1177 # Split on the first colon to separate config key from pattern
1178 parts = condition.split(":", 1)
1179 if len(parts) != 2:
1180 logger.debug("Invalid hasconfig condition format: %r", condition)
1181 return False
1183 config_key, pattern = parts
1185 # Parse the config key to get section and name
1186 key_parts = config_key.split(".", 2)
1187 if len(key_parts) < 2:
1188 logger.debug("Invalid hasconfig config key: %r", config_key)
1189 return False
1191 # Handle wildcards in section names (e.g., remote.*)
1192 if len(key_parts) == 3 and key_parts[1] == "*":
1193 # Match any subsection
1194 section_prefix = key_parts[0].encode(self.encoding)
1195 name = key_parts[2].encode(self.encoding)
1197 # Check all sections that match the pattern
1198 for section in self.sections():
1199 if len(section) == 2 and section[0] == section_prefix:
1200 try:
1201 values = list(self.get_multivar(section, name))
1202 for value in values:
1203 if self._match_hasconfig_pattern(value, pattern):
1204 return True
1205 except KeyError:
1206 continue
1207 else:
1208 # Direct section lookup
1209 if len(key_parts) == 2:
1210 section = (key_parts[0].encode(self.encoding),)
1211 name = key_parts[1].encode(self.encoding)
1212 else:
1213 section = (
1214 key_parts[0].encode(self.encoding),
1215 key_parts[1].encode(self.encoding),
1216 )
1217 name = key_parts[2].encode(self.encoding)
1219 try:
1220 values = list(self.get_multivar(section, name))
1221 for value in values:
1222 if self._match_hasconfig_pattern(value, pattern):
1223 return True
1224 except KeyError:
1225 pass
1227 return False
1229 def _match_hasconfig_pattern(self, value: bytes, pattern: str) -> bool:
1230 """Match a config value against a hasconfig pattern.
1232 Supports simple glob patterns like ``*`` and ``**``.
1233 """
1234 value_str = value.decode(self.encoding, errors="replace")
1235 return match_glob_pattern(value_str, pattern)
1237 @classmethod
1238 def from_path(
1239 cls,
1240 path: str | os.PathLike[str],
1241 *,
1242 max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH,
1243 file_opener: FileOpener | None = None,
1244 condition_matchers: Mapping[str, ConditionMatcher] | None = None,
1245 ) -> "ConfigFile":
1246 """Read configuration from a file on disk.
1248 Args:
1249 path: Path to the configuration file
1250 max_include_depth: Maximum allowed include depth
1251 file_opener: Optional callback to open included files
1252 condition_matchers: Optional dict of condition matchers for includeIf
1253 """
1254 abs_path = os.fspath(path)
1255 config_dir = os.path.dirname(abs_path)
1257 # Use provided file opener or default to GitFile
1258 opener: FileOpener
1259 if file_opener is None:
1261 def opener(p: str | os.PathLike[str]) -> IO[bytes]:
1262 return GitFile(p, "rb")
1263 else:
1264 opener = file_opener
1266 with opener(abs_path) as f:
1267 ret = cls.from_file(
1268 f,
1269 config_dir=config_dir,
1270 max_include_depth=max_include_depth,
1271 file_opener=file_opener,
1272 condition_matchers=condition_matchers,
1273 )
1274 ret.path = abs_path
1275 return ret
1277 def write_to_path(self, path: str | os.PathLike[str] | None = None) -> None:
1278 """Write configuration to a file on disk."""
1279 if path is None:
1280 if self.path is None:
1281 raise ValueError("No path specified and no default path available")
1282 path_to_use: str | os.PathLike[str] = self.path
1283 else:
1284 path_to_use = path
1285 with GitFile(path_to_use, "wb") as f:
1286 self.write_to_file(f)
1288 def write_to_file(self, f: IO[bytes] | _GitFile) -> None:
1289 """Write configuration to a file-like object."""
1290 for section, values in self._values.items():
1291 try:
1292 section_name, subsection_name = section
1293 except ValueError:
1294 (section_name,) = section
1295 subsection_name = None
1296 if subsection_name is None:
1297 f.write(b"[" + section_name + b"]\n")
1298 else:
1299 f.write(b"[" + section_name + b' "' + subsection_name + b'"]\n')
1300 for key, value in values.items():
1301 value = _format_string(value)
1302 f.write(b"\t" + key + b" = " + value + b"\n")
1305def get_xdg_config_home_path(*path_segments: str) -> str:
1306 """Get a path in the XDG config home directory.
1308 Args:
1309 *path_segments: Path segments to join to the XDG config home
1311 Returns:
1312 Full path in XDG config home directory
1313 """
1314 xdg_config_home = os.environ.get(
1315 "XDG_CONFIG_HOME",
1316 os.path.expanduser("~/.config/"),
1317 )
1318 return os.path.join(xdg_config_home, *path_segments)
1321def _find_git_in_win_path() -> Iterator[str]:
1322 for exe in ("git.exe", "git.cmd"):
1323 for path in os.environ.get("PATH", "").split(";"):
1324 if os.path.exists(os.path.join(path, exe)):
1325 # in windows native shells (powershell/cmd) exe path is
1326 # .../Git/bin/git.exe or .../Git/cmd/git.exe
1327 #
1328 # in git-bash exe path is .../Git/mingw64/bin/git.exe
1329 git_dir, _bin_dir = os.path.split(path)
1330 yield git_dir
1331 parent_dir, basename = os.path.split(git_dir)
1332 if basename == "mingw32" or basename == "mingw64":
1333 yield parent_dir
1334 break
1337def _find_git_in_win_reg() -> Iterator[str]:
1338 import platform
1339 import winreg
1341 if platform.machine() == "AMD64":
1342 subkey = (
1343 "SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\"
1344 "CurrentVersion\\Uninstall\\Git_is1"
1345 )
1346 else:
1347 subkey = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Git_is1"
1349 for key in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE): # type: ignore[attr-defined,unused-ignore]
1350 with suppress(OSError):
1351 with winreg.OpenKey(key, subkey) as k: # type: ignore[attr-defined,unused-ignore]
1352 val, typ = winreg.QueryValueEx(k, "InstallLocation") # type: ignore[attr-defined,unused-ignore]
1353 if typ == winreg.REG_SZ: # type: ignore[attr-defined,unused-ignore]
1354 yield val
1357# There is no set standard for system config dirs on windows. We try the
1358# following:
1359# - %PROGRAMFILES%/Git/etc/gitconfig - Git for Windows (msysgit) config dir
1360# Used if CGit installation (Git/bin/git.exe) is found in PATH in the
1361# system registry
1362def get_win_system_paths() -> Iterator[str]:
1363 """Get current Windows system Git config paths.
1365 Only returns the current Git for Windows config location, not legacy paths.
1366 """
1367 # Try to find Git installation from PATH first
1368 for git_dir in _find_git_in_win_path():
1369 yield os.path.join(git_dir, "etc", "gitconfig")
1370 return # Only use the first found path
1372 # Fall back to registry if not found in PATH
1373 for git_dir in _find_git_in_win_reg():
1374 yield os.path.join(git_dir, "etc", "gitconfig")
1375 return # Only use the first found path
1378def get_win_legacy_system_paths() -> Iterator[str]:
1379 """Get legacy Windows system Git config paths.
1381 Returns all possible config paths including deprecated locations.
1382 This function can be used for diagnostics or migration purposes.
1383 """
1384 # Include deprecated PROGRAMDATA location
1385 if "PROGRAMDATA" in os.environ:
1386 yield os.path.join(os.environ["PROGRAMDATA"], "Git", "config")
1388 # Include all Git installations found
1389 for git_dir in _find_git_in_win_path():
1390 yield os.path.join(git_dir, "etc", "gitconfig")
1391 for git_dir in _find_git_in_win_reg():
1392 yield os.path.join(git_dir, "etc", "gitconfig")
1395class StackedConfig(Config):
1396 """Configuration which reads from multiple config files.."""
1398 def __init__(
1399 self, backends: list[ConfigFile], writable: ConfigFile | None = None
1400 ) -> None:
1401 """Initialize a StackedConfig.
1403 Args:
1404 backends: List of config files to read from (in order of precedence)
1405 writable: Optional config file to write changes to
1406 """
1407 self.backends = backends
1408 self.writable = writable
1410 def __repr__(self) -> str:
1411 """Return string representation of StackedConfig."""
1412 return f"<{self.__class__.__name__} for {self.backends!r}>"
1414 @classmethod
1415 def default(cls) -> "StackedConfig":
1416 """Create a StackedConfig with default system/user config files.
1418 Returns:
1419 StackedConfig with default configuration files loaded
1420 """
1421 return cls(cls.default_backends())
1423 @classmethod
1424 def default_backends(cls) -> list[ConfigFile]:
1425 """Retrieve the default configuration.
1427 See git-config(1) for details on the files searched.
1428 """
1429 paths = []
1431 # Handle GIT_CONFIG_GLOBAL - overrides user config paths
1432 try:
1433 paths.append(os.environ["GIT_CONFIG_GLOBAL"])
1434 except KeyError:
1435 paths.append(os.path.expanduser("~/.gitconfig"))
1436 paths.append(get_xdg_config_home_path("git", "config"))
1438 # Handle GIT_CONFIG_SYSTEM and GIT_CONFIG_NOSYSTEM
1439 try:
1440 paths.append(os.environ["GIT_CONFIG_SYSTEM"])
1441 except KeyError:
1442 if "GIT_CONFIG_NOSYSTEM" not in os.environ:
1443 paths.append("/etc/gitconfig")
1444 if sys.platform == "win32":
1445 paths.extend(get_win_system_paths())
1447 logger.debug("Loading gitconfig from paths: %s", paths)
1449 backends = []
1450 for path in paths:
1451 try:
1452 cf = ConfigFile.from_path(path)
1453 logger.debug("Successfully loaded gitconfig from: %s", path)
1454 except FileNotFoundError:
1455 logger.debug("Gitconfig file not found: %s", path)
1456 continue
1457 backends.append(cf)
1458 return backends
1460 def get(self, section: SectionLike, name: NameLike) -> Value:
1461 """Get value from configuration."""
1462 if not isinstance(section, tuple):
1463 section = (section,)
1464 for backend in self.backends:
1465 try:
1466 return backend.get(section, name)
1467 except KeyError:
1468 pass
1469 raise KeyError(name)
1471 def get_multivar(self, section: SectionLike, name: NameLike) -> Iterator[Value]:
1472 """Get multiple values from configuration."""
1473 if not isinstance(section, tuple):
1474 section = (section,)
1475 for backend in self.backends:
1476 try:
1477 yield from backend.get_multivar(section, name)
1478 except KeyError:
1479 pass
1481 def set(
1482 self, section: SectionLike, name: NameLike, value: ValueLike | bool
1483 ) -> None:
1484 """Set value in configuration."""
1485 if self.writable is None:
1486 raise NotImplementedError(self.set)
1487 return self.writable.set(section, name, value)
1489 def sections(self) -> Iterator[Section]:
1490 """Get all sections."""
1491 seen = set()
1492 for backend in self.backends:
1493 for section in backend.sections():
1494 if section not in seen:
1495 seen.add(section)
1496 yield section
1499def read_submodules(
1500 path: str | os.PathLike[str],
1501) -> Iterator[tuple[bytes, bytes, bytes]]:
1502 """Read a .gitmodules file."""
1503 cfg = ConfigFile.from_path(path)
1504 return parse_submodules(cfg)
1507def parse_submodules(config: ConfigFile) -> Iterator[tuple[bytes, bytes, bytes]]:
1508 """Parse a gitmodules GitConfig file, returning submodules.
1510 Args:
1511 config: A `ConfigFile`
1512 Returns:
1513 list of tuples (submodule path, url, name),
1514 where name is quoted part of the section's name.
1515 """
1516 for section in config.sections():
1517 section_kind, section_name = section
1518 if section_kind == b"submodule":
1519 try:
1520 sm_path = config.get(section, b"path")
1521 sm_url = config.get(section, b"url")
1522 yield (sm_path, sm_url, section_name)
1523 except KeyError:
1524 # If either path or url is missing, just ignore this
1525 # submodule entry and move on to the next one. This is
1526 # how git itself handles malformed .gitmodule entries.
1527 pass
1530def iter_instead_of(config: Config, push: bool = False) -> Iterable[tuple[str, str]]:
1531 """Iterate over insteadOf / pushInsteadOf values."""
1532 for section in config.sections():
1533 if section[0] != b"url":
1534 continue
1535 replacement = section[1]
1536 try:
1537 needles = list(config.get_multivar(section, "insteadOf"))
1538 except KeyError:
1539 needles = []
1540 if push:
1541 try:
1542 needles += list(config.get_multivar(section, "pushInsteadOf"))
1543 except KeyError:
1544 pass
1545 for needle in needles:
1546 assert isinstance(needle, bytes)
1547 yield needle.decode("utf-8"), replacement.decode("utf-8")
1550def apply_instead_of(config: Config, orig_url: str, push: bool = False) -> str:
1551 """Apply insteadOf / pushInsteadOf to a URL."""
1552 longest_needle = ""
1553 updated_url = orig_url
1554 for needle, replacement in iter_instead_of(config, push):
1555 if not orig_url.startswith(needle):
1556 continue
1557 if len(longest_needle) < len(needle):
1558 longest_needle = needle
1559 updated_url = replacement + orig_url[len(needle) :]
1560 return updated_url