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 overload,
49)
51from .file import GitFile, _GitFile
53ConfigKey = str | bytes | tuple[str | bytes, ...]
54ConfigValue = str | bytes | bool | int
56logger = logging.getLogger(__name__)
58# Type for file opener callback
59FileOpener = Callable[[str | os.PathLike[str]], IO[bytes]]
61# Type for includeIf condition matcher
62# Takes the condition value (e.g., "main" for onbranch:main) and returns bool
63ConditionMatcher = Callable[[str], bool]
65# Security limits for include files
66MAX_INCLUDE_FILE_SIZE = 1024 * 1024 # 1MB max for included config files
67DEFAULT_MAX_INCLUDE_DEPTH = 10 # Maximum recursion depth for includes
70def _match_gitdir_pattern(
71 path: bytes, pattern: bytes, ignorecase: bool = False
72) -> bool:
73 """Simple gitdir pattern matching for includeIf conditions.
75 This handles the basic gitdir patterns used in includeIf directives.
76 """
77 # Convert to strings for easier manipulation
78 path_str = path.decode("utf-8", errors="replace")
79 pattern_str = pattern.decode("utf-8", errors="replace")
81 # Normalize paths to use forward slashes for consistent matching
82 path_str = path_str.replace("\\", "/")
83 pattern_str = pattern_str.replace("\\", "/")
85 if ignorecase:
86 path_str = path_str.lower()
87 pattern_str = pattern_str.lower()
89 # Handle the common cases for gitdir patterns
90 if pattern_str.startswith("**/") and pattern_str.endswith("/**"):
91 # Pattern like **/dirname/** should match any path containing dirname
92 dirname = pattern_str[3:-3] # Remove **/ and /**
93 # Check if path contains the directory name as a path component
94 return ("/" + dirname + "/") in path_str or path_str.endswith("/" + dirname)
95 elif pattern_str.startswith("**/"):
96 # Pattern like **/filename
97 suffix = pattern_str[3:] # Remove **/
98 return suffix in path_str or path_str.endswith("/" + suffix)
99 elif pattern_str.endswith("/**"):
100 # Pattern like /path/to/dir/** should match /path/to/dir and any subdirectory
101 base_pattern = pattern_str[:-3] # Remove /**
102 return path_str == base_pattern or path_str.startswith(base_pattern + "/")
103 elif "**" in pattern_str:
104 # Handle patterns with ** in the middle
105 parts = pattern_str.split("**")
106 if len(parts) == 2:
107 prefix, suffix = parts
108 # Path must start with prefix and end with suffix (if any)
109 if prefix and not path_str.startswith(prefix):
110 return False
111 if suffix and not path_str.endswith(suffix):
112 return False
113 return True
115 # Direct match or simple glob pattern
116 if "*" in pattern_str or "?" in pattern_str or "[" in pattern_str:
117 import fnmatch
119 return fnmatch.fnmatch(path_str, pattern_str)
120 else:
121 return path_str == pattern_str
124def match_glob_pattern(value: str, pattern: str) -> bool:
125 r"""Match a value against a glob pattern.
127 Supports simple glob patterns like ``*`` and ``**``.
129 Raises:
130 ValueError: If the pattern is invalid
131 """
132 # Convert glob pattern to regex
133 pattern_escaped = re.escape(pattern)
134 # Replace escaped \*\* with .* (match anything)
135 pattern_escaped = pattern_escaped.replace(r"\*\*", ".*")
136 # Replace escaped \* with [^/]* (match anything except /)
137 pattern_escaped = pattern_escaped.replace(r"\*", "[^/]*")
138 # Anchor the pattern
139 pattern_regex = f"^{pattern_escaped}$"
141 try:
142 return bool(re.match(pattern_regex, value))
143 except re.error as e:
144 raise ValueError(f"Invalid glob pattern {pattern!r}: {e}")
147def lower_key(key: ConfigKey) -> ConfigKey:
148 """Convert a config key to lowercase, preserving subsection case.
150 Args:
151 key: Configuration key (str, bytes, or tuple)
153 Returns:
154 Key with section names lowercased, subsection names preserved
156 Raises:
157 TypeError: If key is not str, bytes, or tuple
158 """
159 if isinstance(key, (bytes, str)):
160 return key.lower()
162 if isinstance(key, tuple):
163 # For config sections, only lowercase the section name (first element)
164 # but preserve the case of subsection names (remaining elements)
165 if len(key) > 0:
166 first = key[0]
167 assert isinstance(first, (bytes, str))
168 return (first.lower(), *key[1:])
169 return key
171 raise TypeError(key)
174K = TypeVar("K", bound=ConfigKey) # Key type must be ConfigKey
175V = TypeVar("V") # Value type
176_T = TypeVar("_T") # For get() default parameter
179class CaseInsensitiveOrderedMultiDict(MutableMapping[K, V], Generic[K, V]):
180 """A case-insensitive ordered dictionary that can store multiple values per key.
182 This class maintains the order of insertions and allows multiple values
183 for the same key. Keys are compared case-insensitively.
184 """
186 def __init__(self, default_factory: Callable[[], V] | None = None) -> None:
187 """Initialize a CaseInsensitiveOrderedMultiDict.
189 Args:
190 default_factory: Optional factory function for default values
191 """
192 self._real: list[tuple[K, V]] = []
193 self._keyed: dict[ConfigKey, V] = {}
194 self._default_factory = default_factory
196 @classmethod
197 def make(
198 cls,
199 dict_in: "MutableMapping[K, V] | CaseInsensitiveOrderedMultiDict[K, V] | None" = None,
200 default_factory: Callable[[], V] | None = None,
201 ) -> "CaseInsensitiveOrderedMultiDict[K, V]":
202 """Create a CaseInsensitiveOrderedMultiDict from an existing mapping.
204 Args:
205 dict_in: Optional mapping to initialize from
206 default_factory: Optional factory function for default values
208 Returns:
209 New CaseInsensitiveOrderedMultiDict instance
211 Raises:
212 TypeError: If dict_in is not a mapping or None
213 """
214 if isinstance(dict_in, cls):
215 return dict_in
217 out = cls(default_factory=default_factory)
219 if dict_in is None:
220 return out
222 if not isinstance(dict_in, MutableMapping):
223 raise TypeError
225 for key, value in dict_in.items():
226 out[key] = value
228 return out
230 def __len__(self) -> int:
231 """Return the number of unique keys in the dictionary."""
232 return len(self._keyed)
234 def keys(self) -> KeysView[K]:
235 """Return a view of the dictionary's keys."""
236 # Return a view of the original keys (not lowercased)
237 # We need to deduplicate since _real can have duplicates
238 seen = set()
239 unique_keys = []
240 for k, _ in self._real:
241 lower = lower_key(k)
242 if lower not in seen:
243 seen.add(lower)
244 unique_keys.append(k)
245 from collections.abc import KeysView as ABCKeysView
247 class UniqueKeysView(ABCKeysView[K]):
248 def __init__(self, keys: list[K]):
249 self._keys = keys
251 def __contains__(self, key: object) -> bool:
252 return key in self._keys
254 def __iter__(self) -> Iterator[K]:
255 return iter(self._keys)
257 def __len__(self) -> int:
258 return len(self._keys)
260 return UniqueKeysView(unique_keys)
262 def items(self) -> ItemsView[K, V]:
263 """Return a view of the dictionary's (key, value) pairs in insertion order."""
265 # Return a view that iterates over the real list to preserve order
266 class OrderedItemsView(ItemsView[K, V]):
267 """Items view that preserves insertion order."""
269 def __init__(self, mapping: CaseInsensitiveOrderedMultiDict[K, V]):
270 self._mapping = mapping
272 def __iter__(self) -> Iterator[tuple[K, V]]:
273 return iter(self._mapping._real)
275 def __len__(self) -> int:
276 return len(self._mapping._real)
278 def __contains__(self, item: object) -> bool:
279 if not isinstance(item, tuple) or len(item) != 2:
280 return False
281 key, value = item
282 return any(k == key and v == value for k, v in self._mapping._real)
284 return OrderedItemsView(self)
286 def __iter__(self) -> Iterator[K]:
287 """Iterate over the dictionary's keys."""
288 # Return iterator over original keys (not lowercased), deduplicated
289 seen = set()
290 for k, _ in self._real:
291 lower = lower_key(k)
292 if lower not in seen:
293 seen.add(lower)
294 yield k
296 def values(self) -> ValuesView[V]:
297 """Return a view of the dictionary's values."""
298 return self._keyed.values()
300 def __setitem__(self, key: K, value: V) -> None:
301 """Set a value for a key, appending to existing values."""
302 self._real.append((key, value))
303 self._keyed[lower_key(key)] = value
305 def set(self, key: K, value: V) -> None:
306 """Set a value for a key, replacing all existing values.
308 Args:
309 key: The key to set
310 value: The value to set
311 """
312 # This method replaces all existing values for the key
313 lower = lower_key(key)
314 self._real = [(k, v) for k, v in self._real if lower_key(k) != lower]
315 self._real.append((key, value))
316 self._keyed[lower] = value
318 def __delitem__(self, key: K) -> None:
319 """Delete all values for a key.
321 Raises:
322 KeyError: If the key is not found
323 """
324 lower_k = lower_key(key)
325 del self._keyed[lower_k]
326 for i, (actual, unused_value) in reversed(list(enumerate(self._real))):
327 if lower_key(actual) == lower_k:
328 del self._real[i]
330 def __getitem__(self, item: K) -> V:
331 """Get the last value for a key.
333 Raises:
334 KeyError: If the key is not found
335 """
336 return self._keyed[lower_key(item)]
338 def get(self, key: K, /, default: V | _T | None = None) -> V | _T | None: # type: ignore[override]
339 """Get the last value for a key, or a default if not found.
341 Args:
342 key: The key to look up
343 default: Default value to return if key not found
345 Returns:
346 The value for the key, or default/default_factory result if not found
347 """
348 try:
349 return self[key]
350 except KeyError:
351 if default is not None:
352 return default
353 elif self._default_factory is not None:
354 return self._default_factory()
355 else:
356 return None
358 def get_all(self, key: K) -> Iterator[V]:
359 """Get all values for a key in insertion order.
361 Args:
362 key: The key to look up
364 Returns:
365 Iterator of all values for the key
366 """
367 lowered_key = lower_key(key)
368 for actual, value in self._real:
369 if lower_key(actual) == lowered_key:
370 yield value
372 def setdefault(self, key: K, default: V | None = None) -> V:
373 """Get value for key, setting it to default if not present.
375 Args:
376 key: The key to look up
377 default: Default value to set if key not found
379 Returns:
380 The existing value or the newly set default
382 Raises:
383 KeyError: If key not found and no default or default_factory
384 """
385 try:
386 return self[key]
387 except KeyError:
388 if default is not None:
389 self[key] = default
390 return default
391 elif self._default_factory is not None:
392 value = self._default_factory()
393 self[key] = value
394 return value
395 else:
396 raise
399Name = bytes
400NameLike = bytes | str
401Section = tuple[bytes, ...]
402SectionLike = bytes | str | tuple[bytes | str, ...]
403Value = bytes
404ValueLike = bytes | str
407class Config:
408 """A Git configuration."""
410 def get(self, section: SectionLike, name: NameLike) -> Value:
411 """Retrieve the contents of a configuration setting.
413 Args:
414 section: Tuple with section name and optional subsection name
415 name: Variable name
416 Returns:
417 Contents of the setting
418 Raises:
419 KeyError: if the value is not set
420 """
421 raise NotImplementedError(self.get)
423 def get_multivar(self, section: SectionLike, name: NameLike) -> Iterator[Value]:
424 """Retrieve the contents of a multivar configuration setting.
426 Args:
427 section: Tuple with section name and optional subsection namee
428 name: Variable name
429 Returns:
430 Contents of the setting as iterable
431 Raises:
432 KeyError: if the value is not set
433 """
434 raise NotImplementedError(self.get_multivar)
436 @overload
437 def get_boolean(
438 self, section: SectionLike, name: NameLike, default: bool
439 ) -> bool: ...
441 @overload
442 def get_boolean(self, section: SectionLike, name: NameLike) -> bool | None: ...
444 def get_boolean(
445 self, section: SectionLike, name: NameLike, default: bool | None = None
446 ) -> bool | None:
447 """Retrieve a configuration setting as boolean.
449 Args:
450 section: Tuple with section name and optional subsection name
451 name: Name of the setting, including section and possible
452 subsection.
453 default: Default value if setting is not found
455 Returns:
456 Contents of the setting
457 """
458 try:
459 value = self.get(section, name)
460 except KeyError:
461 return default
462 if value.lower() == b"true":
463 return True
464 elif value.lower() == b"false":
465 return False
466 raise ValueError(f"not a valid boolean string: {value!r}")
468 def set(
469 self, section: SectionLike, name: NameLike, value: ValueLike | bool
470 ) -> None:
471 """Set a configuration value.
473 Args:
474 section: Tuple with section name and optional subsection namee
475 name: Name of the configuration value, including section
476 and optional subsection
477 value: value of the setting
478 """
479 raise NotImplementedError(self.set)
481 def items(self, section: SectionLike) -> Iterator[tuple[Name, Value]]:
482 """Iterate over the configuration pairs for a specific section.
484 Args:
485 section: Tuple with section name and optional subsection namee
486 Returns:
487 Iterator over (name, value) pairs
488 """
489 raise NotImplementedError(self.items)
491 def sections(self) -> Iterator[Section]:
492 """Iterate over the sections.
494 Returns: Iterator over section tuples
495 """
496 raise NotImplementedError(self.sections)
498 def has_section(self, name: Section) -> bool:
499 """Check if a specified section exists.
501 Args:
502 name: Name of section to check for
503 Returns:
504 boolean indicating whether the section exists
505 """
506 return name in self.sections()
509class ConfigDict(Config):
510 """Git configuration stored in a dictionary."""
512 def __init__(
513 self,
514 values: MutableMapping[Section, CaseInsensitiveOrderedMultiDict[Name, Value]]
515 | None = None,
516 encoding: str | None = None,
517 ) -> None:
518 """Create a new ConfigDict."""
519 if encoding is None:
520 encoding = sys.getdefaultencoding()
521 self.encoding = encoding
522 self._values: CaseInsensitiveOrderedMultiDict[
523 Section, CaseInsensitiveOrderedMultiDict[Name, Value]
524 ] = CaseInsensitiveOrderedMultiDict.make(
525 values, default_factory=CaseInsensitiveOrderedMultiDict
526 )
528 def __repr__(self) -> str:
529 """Return string representation of ConfigDict."""
530 return f"{self.__class__.__name__}({self._values!r})"
532 def __eq__(self, other: object) -> bool:
533 """Check equality with another ConfigDict."""
534 return isinstance(other, self.__class__) and other._values == self._values
536 def __getitem__(self, key: Section) -> CaseInsensitiveOrderedMultiDict[Name, Value]:
537 """Get configuration values for a section.
539 Raises:
540 KeyError: If section not found
541 """
542 return self._values.__getitem__(key)
544 def __setitem__(
545 self, key: Section, value: CaseInsensitiveOrderedMultiDict[Name, Value]
546 ) -> None:
547 """Set configuration values for a section."""
548 return self._values.__setitem__(key, value)
550 def __delitem__(self, key: Section) -> None:
551 """Delete a configuration section.
553 Raises:
554 KeyError: If section not found
555 """
556 return self._values.__delitem__(key)
558 def __iter__(self) -> Iterator[Section]:
559 """Iterate over configuration sections."""
560 return self._values.__iter__()
562 def __len__(self) -> int:
563 """Return the number of sections."""
564 return self._values.__len__()
566 def keys(self) -> KeysView[Section]:
567 """Return a view of section names."""
568 return self._values.keys()
570 @classmethod
571 def _parse_setting(cls, name: str) -> tuple[str, str | None, str]:
572 parts = name.split(".")
573 if len(parts) == 3:
574 return (parts[0], parts[1], parts[2])
575 else:
576 return (parts[0], None, parts[1])
578 def _check_section_and_name(
579 self, section: SectionLike, name: NameLike
580 ) -> tuple[Section, Name]:
581 if not isinstance(section, tuple):
582 section = (section,)
584 checked_section = tuple(
585 [
586 subsection.encode(self.encoding)
587 if not isinstance(subsection, bytes)
588 else subsection
589 for subsection in section
590 ]
591 )
593 if not isinstance(name, bytes):
594 name = name.encode(self.encoding)
596 return checked_section, name
598 def get_multivar(self, section: SectionLike, name: NameLike) -> Iterator[Value]:
599 """Get multiple values for a configuration setting.
601 Args:
602 section: Section name
603 name: Setting name
605 Returns:
606 Iterator of configuration values
607 """
608 section, name = self._check_section_and_name(section, name)
610 if len(section) > 1:
611 try:
612 return self._values[section].get_all(name)
613 except KeyError:
614 pass
616 return self._values[(section[0],)].get_all(name)
618 def get(
619 self,
620 section: SectionLike,
621 name: NameLike,
622 ) -> Value:
623 """Get a configuration value.
625 Args:
626 section: Section name
627 name: Setting name
629 Returns:
630 Configuration value
632 Raises:
633 KeyError: if the value is not set
634 """
635 section, name = self._check_section_and_name(section, name)
637 if len(section) > 1:
638 try:
639 return self._values[section][name]
640 except KeyError:
641 pass
643 return self._values[(section[0],)][name]
645 def set(
646 self,
647 section: SectionLike,
648 name: NameLike,
649 value: ValueLike | bool,
650 ) -> None:
651 """Set a configuration value.
653 Args:
654 section: Section name
655 name: Setting name
656 value: Configuration value
657 """
658 section, name = self._check_section_and_name(section, name)
660 if isinstance(value, bool):
661 value = b"true" if value else b"false"
663 if not isinstance(value, bytes):
664 value = value.encode(self.encoding)
666 section_dict = self._values.setdefault(section)
667 if hasattr(section_dict, "set"):
668 section_dict.set(name, value)
669 else:
670 section_dict[name] = value
672 def add(
673 self,
674 section: SectionLike,
675 name: NameLike,
676 value: ValueLike | bool,
677 ) -> None:
678 """Add a value to a configuration setting, creating a multivar if needed."""
679 section, name = self._check_section_and_name(section, name)
681 if isinstance(value, bool):
682 value = b"true" if value else b"false"
684 if not isinstance(value, bytes):
685 value = value.encode(self.encoding)
687 self._values.setdefault(section)[name] = value
689 def items(self, section: SectionLike) -> Iterator[tuple[Name, Value]]:
690 """Get items in a section."""
691 section_bytes, _ = self._check_section_and_name(section, b"")
692 section_dict = self._values.get(section_bytes)
693 if section_dict is not None:
694 return iter(section_dict.items())
695 return iter([])
697 def sections(self) -> Iterator[Section]:
698 """Get all sections."""
699 return iter(self._values.keys())
702def _format_string(value: bytes) -> bytes:
703 if (
704 value.startswith((b" ", b"\t"))
705 or value.endswith((b" ", b"\t"))
706 or b"#" in value
707 ):
708 return b'"' + _escape_value(value) + b'"'
709 else:
710 return _escape_value(value)
713_ESCAPE_TABLE = {
714 ord(b"\\"): ord(b"\\"),
715 ord(b'"'): ord(b'"'),
716 ord(b"n"): ord(b"\n"),
717 ord(b"t"): ord(b"\t"),
718 ord(b"b"): ord(b"\b"),
719}
720_COMMENT_CHARS = [ord(b"#"), ord(b";")]
721_WHITESPACE_CHARS = [ord(b"\t"), ord(b" ")]
724def _parse_string(value: bytes) -> bytes:
725 value_array = bytearray(value.strip())
726 ret = bytearray()
727 whitespace = bytearray()
728 in_quotes = False
729 i = 0
730 while i < len(value_array):
731 c = value_array[i]
732 if c == ord(b"\\"):
733 i += 1
734 if i >= len(value_array):
735 # Backslash at end of string - treat as literal backslash
736 if whitespace:
737 ret.extend(whitespace)
738 whitespace = bytearray()
739 ret.append(ord(b"\\"))
740 else:
741 try:
742 v = _ESCAPE_TABLE[value_array[i]]
743 if whitespace:
744 ret.extend(whitespace)
745 whitespace = bytearray()
746 ret.append(v)
747 except KeyError:
748 # Unknown escape sequence - treat backslash as literal and process next char normally
749 if whitespace:
750 ret.extend(whitespace)
751 whitespace = bytearray()
752 ret.append(ord(b"\\"))
753 i -= 1 # Reprocess the character after the backslash
754 elif c == ord(b'"'):
755 in_quotes = not in_quotes
756 elif c in _COMMENT_CHARS and not in_quotes:
757 # the rest of the line is a comment
758 break
759 elif c in _WHITESPACE_CHARS:
760 whitespace.append(c)
761 else:
762 if whitespace:
763 ret.extend(whitespace)
764 whitespace = bytearray()
765 ret.append(c)
766 i += 1
768 if in_quotes:
769 raise ValueError("missing end quote")
771 return bytes(ret)
774def _escape_value(value: bytes) -> bytes:
775 """Escape a value."""
776 value = value.replace(b"\\", b"\\\\")
777 value = value.replace(b"\r", b"\\r")
778 value = value.replace(b"\n", b"\\n")
779 value = value.replace(b"\t", b"\\t")
780 value = value.replace(b'"', b'\\"')
781 return value
784def _check_variable_name(name: bytes) -> bool:
785 for i in range(len(name)):
786 c = name[i : i + 1]
787 if not c.isalnum() and c != b"-":
788 return False
789 return True
792def _check_section_name(name: bytes) -> bool:
793 for i in range(len(name)):
794 c = name[i : i + 1]
795 if not c.isalnum() and c not in (b"-", b"."):
796 return False
797 return True
800def _strip_comments(line: bytes) -> bytes:
801 comment_bytes = {ord(b"#"), ord(b";")}
802 quote = ord(b'"')
803 string_open = False
804 # Normalize line to bytearray for simple 2/3 compatibility
805 for i, character in enumerate(bytearray(line)):
806 # Comment characters outside balanced quotes denote comment start
807 if character == quote:
808 string_open = not string_open
809 elif not string_open and character in comment_bytes:
810 return line[:i]
811 return line
814def _is_line_continuation(value: bytes) -> bool:
815 """Check if a value ends with a line continuation backslash.
817 A line continuation occurs when a line ends with a backslash that is:
818 1. Not escaped (not preceded by another backslash)
819 2. Not within quotes
821 Args:
822 value: The value to check
824 Returns:
825 True if the value ends with a line continuation backslash
826 """
827 if not value.endswith((b"\\\n", b"\\\r\n")):
828 return False
830 # Remove only the newline characters, keep the content including the backslash
831 if value.endswith(b"\\\r\n"):
832 content = value[:-2] # Remove \r\n, keep the \
833 else:
834 content = value[:-1] # Remove \n, keep the \
836 if not content.endswith(b"\\"):
837 return False
839 # Count consecutive backslashes at the end
840 backslash_count = 0
841 for i in range(len(content) - 1, -1, -1):
842 if content[i : i + 1] == b"\\":
843 backslash_count += 1
844 else:
845 break
847 # If we have an odd number of backslashes, the last one is a line continuation
848 # If we have an even number, they are all escaped and there's no continuation
849 return backslash_count % 2 == 1
852def _parse_section_header_line(line: bytes) -> tuple[Section, bytes]:
853 # Parse section header ("[bla]")
854 line = _strip_comments(line).rstrip()
855 in_quotes = False
856 escaped = False
857 for i, c in enumerate(line):
858 if escaped:
859 escaped = False
860 continue
861 if c == ord(b'"'):
862 in_quotes = not in_quotes
863 if c == ord(b"\\"):
864 escaped = True
865 if c == ord(b"]") and not in_quotes:
866 last = i
867 break
868 else:
869 raise ValueError("expected trailing ]")
870 pts = line[1:last].split(b" ", 1)
871 line = line[last + 1 :]
872 section: Section
873 if len(pts) == 2:
874 # Handle subsections - Git allows more complex syntax for certain sections like includeIf
875 if pts[1][:1] == b'"' and pts[1][-1:] == b'"':
876 # Standard quoted subsection
877 pts[1] = pts[1][1:-1]
878 elif pts[0] == b"includeIf":
879 # Special handling for includeIf sections which can have complex conditions
880 # Git allows these without strict quote validation
881 pts[1] = pts[1].strip()
882 if pts[1][:1] == b'"' and pts[1][-1:] == b'"':
883 pts[1] = pts[1][1:-1]
884 else:
885 # Other sections must have quoted subsections
886 raise ValueError(f"Invalid subsection {pts[1]!r}")
887 if not _check_section_name(pts[0]):
888 raise ValueError(f"invalid section name {pts[0]!r}")
889 section = (pts[0], pts[1])
890 else:
891 if not _check_section_name(pts[0]):
892 raise ValueError(f"invalid section name {pts[0]!r}")
893 pts = pts[0].split(b".", 1)
894 if len(pts) == 2:
895 section = (pts[0], pts[1])
896 else:
897 section = (pts[0],)
898 return section, line
901class ConfigFile(ConfigDict):
902 """A Git configuration file, like .git/config or ~/.gitconfig."""
904 def __init__(
905 self,
906 values: MutableMapping[Section, CaseInsensitiveOrderedMultiDict[Name, Value]]
907 | None = None,
908 encoding: str | None = None,
909 ) -> None:
910 """Initialize a ConfigFile.
912 Args:
913 values: Optional mapping of configuration values
914 encoding: Optional encoding for the file (defaults to system encoding)
915 """
916 super().__init__(values=values, encoding=encoding)
917 self.path: str | None = None
918 self._included_paths: set[str] = set() # Track included files to prevent cycles
920 @classmethod
921 def from_file(
922 cls,
923 f: IO[bytes],
924 *,
925 config_dir: str | None = None,
926 included_paths: set[str] | None = None,
927 include_depth: int = 0,
928 max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH,
929 file_opener: FileOpener | None = None,
930 condition_matchers: Mapping[str, ConditionMatcher] | None = None,
931 ) -> "ConfigFile":
932 """Read configuration from a file-like object.
934 Args:
935 f: File-like object to read from
936 config_dir: Directory containing the config file (for relative includes)
937 included_paths: Set of already included paths (to prevent cycles)
938 include_depth: Current include depth (to prevent infinite recursion)
939 max_include_depth: Maximum allowed include depth
940 file_opener: Optional callback to open included files
941 condition_matchers: Optional dict of condition matchers for includeIf
942 """
943 if include_depth > max_include_depth:
944 # Prevent excessive recursion
945 raise ValueError(f"Maximum include depth ({max_include_depth}) exceeded")
947 ret = cls()
948 if included_paths is not None:
949 ret._included_paths = included_paths.copy()
951 section: Section | None = None
952 setting = None
953 continuation = None
954 for lineno, line in enumerate(f.readlines()):
955 if lineno == 0 and line.startswith(b"\xef\xbb\xbf"):
956 line = line[3:]
957 line = line.lstrip()
958 if setting is None:
959 if len(line) > 0 and line[:1] == b"[":
960 section, line = _parse_section_header_line(line)
961 ret._values.setdefault(section)
962 if _strip_comments(line).strip() == b"":
963 continue
964 if section is None:
965 raise ValueError(f"setting {line!r} without section")
966 try:
967 setting, value = line.split(b"=", 1)
968 except ValueError:
969 setting = line
970 value = b"true"
971 setting = setting.strip()
972 if not _check_variable_name(setting):
973 raise ValueError(f"invalid variable name {setting!r}")
974 if _is_line_continuation(value):
975 if value.endswith(b"\\\r\n"):
976 continuation = value[:-3]
977 else:
978 continuation = value[:-2]
979 else:
980 continuation = None
981 value = _parse_string(value)
982 ret._values[section][setting] = value
984 # Process include/includeIf directives
985 ret._handle_include_directive(
986 section,
987 setting,
988 value,
989 config_dir=config_dir,
990 include_depth=include_depth,
991 max_include_depth=max_include_depth,
992 file_opener=file_opener,
993 condition_matchers=condition_matchers,
994 )
996 setting = None
997 else: # continuation line
998 assert continuation is not None
999 if _is_line_continuation(line):
1000 if line.endswith(b"\\\r\n"):
1001 continuation += line[:-3]
1002 else:
1003 continuation += line[:-2]
1004 else:
1005 continuation += line
1006 value = _parse_string(continuation)
1007 assert section is not None # Already checked above
1008 ret._values[section][setting] = value
1010 # Process include/includeIf directives
1011 ret._handle_include_directive(
1012 section,
1013 setting,
1014 value,
1015 config_dir=config_dir,
1016 include_depth=include_depth,
1017 max_include_depth=max_include_depth,
1018 file_opener=file_opener,
1019 condition_matchers=condition_matchers,
1020 )
1022 continuation = None
1023 setting = None
1024 return ret
1026 def _handle_include_directive(
1027 self,
1028 section: Section | None,
1029 setting: bytes,
1030 value: bytes,
1031 *,
1032 config_dir: str | None,
1033 include_depth: int,
1034 max_include_depth: int,
1035 file_opener: FileOpener | None,
1036 condition_matchers: Mapping[str, ConditionMatcher] | None,
1037 ) -> None:
1038 """Handle include/includeIf directives during config parsing."""
1039 if (
1040 section is not None
1041 and setting == b"path"
1042 and (
1043 section[0].lower() == b"include"
1044 or (len(section) > 1 and section[0].lower() == b"includeif")
1045 )
1046 ):
1047 self._process_include(
1048 section,
1049 value,
1050 config_dir=config_dir,
1051 include_depth=include_depth,
1052 max_include_depth=max_include_depth,
1053 file_opener=file_opener,
1054 condition_matchers=condition_matchers,
1055 )
1057 def _process_include(
1058 self,
1059 section: Section,
1060 path_value: bytes,
1061 *,
1062 config_dir: str | None,
1063 include_depth: int,
1064 max_include_depth: int,
1065 file_opener: FileOpener | None,
1066 condition_matchers: Mapping[str, ConditionMatcher] | None,
1067 ) -> None:
1068 """Process an include or includeIf directive."""
1069 path_str = path_value.decode(self.encoding, errors="replace")
1071 # Handle includeIf conditions
1072 if len(section) > 1 and section[0].lower() == b"includeif":
1073 condition = section[1].decode(self.encoding, errors="replace")
1074 if not self._evaluate_includeif_condition(
1075 condition, config_dir, condition_matchers
1076 ):
1077 return
1079 # Resolve the include path
1080 include_path = self._resolve_include_path(path_str, config_dir)
1081 if not include_path:
1082 return
1084 # Check for circular includes
1085 try:
1086 abs_path = str(Path(include_path).resolve())
1087 except (OSError, ValueError) as e:
1088 # Invalid path - log and skip
1089 logger.debug("Invalid include path %r: %s", include_path, e)
1090 return
1091 if abs_path in self._included_paths:
1092 return
1094 # Load and merge the included file
1095 try:
1096 # Use provided file opener or default to GitFile
1097 opener: FileOpener
1098 if file_opener is None:
1100 def opener(path: str | os.PathLike[str]) -> IO[bytes]:
1101 return GitFile(path, "rb")
1102 else:
1103 opener = file_opener
1105 f = opener(include_path)
1106 except (OSError, ValueError) as e:
1107 # Git silently ignores missing or unreadable include files
1108 # Log for debugging purposes
1109 logger.debug("Invalid include path %r: %s", include_path, e)
1110 else:
1111 with f as included_file:
1112 # Track this path to prevent cycles
1113 self._included_paths.add(abs_path)
1115 # Parse the included file
1116 included_config = ConfigFile.from_file(
1117 included_file,
1118 config_dir=os.path.dirname(include_path),
1119 included_paths=self._included_paths,
1120 include_depth=include_depth + 1,
1121 max_include_depth=max_include_depth,
1122 file_opener=file_opener,
1123 condition_matchers=condition_matchers,
1124 )
1126 # Merge the included configuration
1127 self._merge_config(included_config)
1129 def _merge_config(self, other: "ConfigFile") -> None:
1130 """Merge another config file into this one."""
1131 for section, values in other._values.items():
1132 if section not in self._values:
1133 self._values[section] = CaseInsensitiveOrderedMultiDict()
1134 for key, value in values.items():
1135 self._values[section][key] = value
1137 def _resolve_include_path(self, path: str, config_dir: str | None) -> str | None:
1138 """Resolve an include path to an absolute path."""
1139 # Expand ~ to home directory
1140 path = os.path.expanduser(path)
1142 # If path is relative and we have a config directory, make it relative to that
1143 if not os.path.isabs(path) and config_dir:
1144 path = os.path.join(config_dir, path)
1146 return path
1148 def _evaluate_includeif_condition(
1149 self,
1150 condition: str,
1151 config_dir: str | None = None,
1152 condition_matchers: Mapping[str, ConditionMatcher] | None = None,
1153 ) -> bool:
1154 """Evaluate an includeIf condition."""
1155 # Try custom matchers first if provided
1156 if condition_matchers:
1157 for prefix, matcher in condition_matchers.items():
1158 if condition.startswith(prefix):
1159 return matcher(condition[len(prefix) :])
1161 # Fall back to built-in matchers
1162 if condition.startswith("hasconfig:"):
1163 return self._evaluate_hasconfig_condition(condition[10:])
1164 else:
1165 # Unknown condition type - log and ignore (Git behavior)
1166 logger.debug("Unknown includeIf condition: %r", condition)
1167 return False
1169 def _evaluate_hasconfig_condition(self, condition: str) -> bool:
1170 """Evaluate a hasconfig condition.
1172 Format: hasconfig:config.key:pattern
1173 Example: hasconfig:remote.*.url:ssh://org-*@github.com/**
1174 """
1175 # Split on the first colon to separate config key from pattern
1176 parts = condition.split(":", 1)
1177 if len(parts) != 2:
1178 logger.debug("Invalid hasconfig condition format: %r", condition)
1179 return False
1181 config_key, pattern = parts
1183 # Parse the config key to get section and name
1184 key_parts = config_key.split(".", 2)
1185 if len(key_parts) < 2:
1186 logger.debug("Invalid hasconfig config key: %r", config_key)
1187 return False
1189 # Handle wildcards in section names (e.g., remote.*)
1190 if len(key_parts) == 3 and key_parts[1] == "*":
1191 # Match any subsection
1192 section_prefix = key_parts[0].encode(self.encoding)
1193 name = key_parts[2].encode(self.encoding)
1195 # Check all sections that match the pattern
1196 for section in self.sections():
1197 if len(section) == 2 and section[0] == section_prefix:
1198 try:
1199 values = list(self.get_multivar(section, name))
1200 for value in values:
1201 if self._match_hasconfig_pattern(value, pattern):
1202 return True
1203 except KeyError:
1204 continue
1205 else:
1206 # Direct section lookup
1207 if len(key_parts) == 2:
1208 section = (key_parts[0].encode(self.encoding),)
1209 name = key_parts[1].encode(self.encoding)
1210 else:
1211 section = (
1212 key_parts[0].encode(self.encoding),
1213 key_parts[1].encode(self.encoding),
1214 )
1215 name = key_parts[2].encode(self.encoding)
1217 try:
1218 values = list(self.get_multivar(section, name))
1219 for value in values:
1220 if self._match_hasconfig_pattern(value, pattern):
1221 return True
1222 except KeyError:
1223 pass
1225 return False
1227 def _match_hasconfig_pattern(self, value: bytes, pattern: str) -> bool:
1228 """Match a config value against a hasconfig pattern.
1230 Supports simple glob patterns like ``*`` and ``**``.
1231 """
1232 value_str = value.decode(self.encoding, errors="replace")
1233 return match_glob_pattern(value_str, pattern)
1235 @classmethod
1236 def from_path(
1237 cls,
1238 path: str | os.PathLike[str],
1239 *,
1240 max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH,
1241 file_opener: FileOpener | None = None,
1242 condition_matchers: Mapping[str, ConditionMatcher] | None = None,
1243 ) -> "ConfigFile":
1244 """Read configuration from a file on disk.
1246 Args:
1247 path: Path to the configuration file
1248 max_include_depth: Maximum allowed include depth
1249 file_opener: Optional callback to open included files
1250 condition_matchers: Optional dict of condition matchers for includeIf
1251 """
1252 abs_path = os.fspath(path)
1253 config_dir = os.path.dirname(abs_path)
1255 # Use provided file opener or default to GitFile
1256 opener: FileOpener
1257 if file_opener is None:
1259 def opener(p: str | os.PathLike[str]) -> IO[bytes]:
1260 return GitFile(p, "rb")
1261 else:
1262 opener = file_opener
1264 with opener(abs_path) as f:
1265 ret = cls.from_file(
1266 f,
1267 config_dir=config_dir,
1268 max_include_depth=max_include_depth,
1269 file_opener=file_opener,
1270 condition_matchers=condition_matchers,
1271 )
1272 ret.path = abs_path
1273 return ret
1275 def write_to_path(self, path: str | os.PathLike[str] | None = None) -> None:
1276 """Write configuration to a file on disk."""
1277 if path is None:
1278 if self.path is None:
1279 raise ValueError("No path specified and no default path available")
1280 path_to_use: str | os.PathLike[str] = self.path
1281 else:
1282 path_to_use = path
1283 with GitFile(path_to_use, "wb") as f:
1284 self.write_to_file(f)
1286 def write_to_file(self, f: IO[bytes] | _GitFile) -> None:
1287 """Write configuration to a file-like object."""
1288 for section, values in self._values.items():
1289 try:
1290 section_name, subsection_name = section
1291 except ValueError:
1292 (section_name,) = section
1293 subsection_name = None
1294 if subsection_name is None:
1295 f.write(b"[" + section_name + b"]\n")
1296 else:
1297 f.write(b"[" + section_name + b' "' + subsection_name + b'"]\n')
1298 for key, value in values.items():
1299 value = _format_string(value)
1300 f.write(b"\t" + key + b" = " + value + b"\n")
1303def get_xdg_config_home_path(*path_segments: str) -> str:
1304 """Get a path in the XDG config home directory.
1306 Args:
1307 *path_segments: Path segments to join to the XDG config home
1309 Returns:
1310 Full path in XDG config home directory
1311 """
1312 xdg_config_home = os.environ.get(
1313 "XDG_CONFIG_HOME",
1314 os.path.expanduser("~/.config/"),
1315 )
1316 return os.path.join(xdg_config_home, *path_segments)
1319def _find_git_in_win_path() -> Iterator[str]:
1320 for exe in ("git.exe", "git.cmd"):
1321 for path in os.environ.get("PATH", "").split(";"):
1322 if os.path.exists(os.path.join(path, exe)):
1323 # in windows native shells (powershell/cmd) exe path is
1324 # .../Git/bin/git.exe or .../Git/cmd/git.exe
1325 #
1326 # in git-bash exe path is .../Git/mingw64/bin/git.exe
1327 git_dir, _bin_dir = os.path.split(path)
1328 yield git_dir
1329 parent_dir, basename = os.path.split(git_dir)
1330 if basename == "mingw32" or basename == "mingw64":
1331 yield parent_dir
1332 break
1335def _find_git_in_win_reg() -> Iterator[str]:
1336 import platform
1337 import winreg
1339 if platform.machine() == "AMD64":
1340 subkey = (
1341 "SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\"
1342 "CurrentVersion\\Uninstall\\Git_is1"
1343 )
1344 else:
1345 subkey = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Git_is1"
1347 for key in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE): # type: ignore[attr-defined,unused-ignore]
1348 with suppress(OSError):
1349 with winreg.OpenKey(key, subkey) as k: # type: ignore[attr-defined,unused-ignore]
1350 val, typ = winreg.QueryValueEx(k, "InstallLocation") # type: ignore[attr-defined,unused-ignore]
1351 if typ == winreg.REG_SZ: # type: ignore[attr-defined,unused-ignore]
1352 yield val
1355# There is no set standard for system config dirs on windows. We try the
1356# following:
1357# - %PROGRAMFILES%/Git/etc/gitconfig - Git for Windows (msysgit) config dir
1358# Used if CGit installation (Git/bin/git.exe) is found in PATH in the
1359# system registry
1360def get_win_system_paths() -> Iterator[str]:
1361 """Get current Windows system Git config paths.
1363 Only returns the current Git for Windows config location, not legacy paths.
1364 """
1365 # Try to find Git installation from PATH first
1366 for git_dir in _find_git_in_win_path():
1367 yield os.path.join(git_dir, "etc", "gitconfig")
1368 return # Only use the first found path
1370 # Fall back to registry if not found in PATH
1371 for git_dir in _find_git_in_win_reg():
1372 yield os.path.join(git_dir, "etc", "gitconfig")
1373 return # Only use the first found path
1376def get_win_legacy_system_paths() -> Iterator[str]:
1377 """Get legacy Windows system Git config paths.
1379 Returns all possible config paths including deprecated locations.
1380 This function can be used for diagnostics or migration purposes.
1381 """
1382 # Include deprecated PROGRAMDATA location
1383 if "PROGRAMDATA" in os.environ:
1384 yield os.path.join(os.environ["PROGRAMDATA"], "Git", "config")
1386 # Include all Git installations found
1387 for git_dir in _find_git_in_win_path():
1388 yield os.path.join(git_dir, "etc", "gitconfig")
1389 for git_dir in _find_git_in_win_reg():
1390 yield os.path.join(git_dir, "etc", "gitconfig")
1393class StackedConfig(Config):
1394 """Configuration which reads from multiple config files.."""
1396 def __init__(
1397 self, backends: list[ConfigFile], writable: ConfigFile | None = None
1398 ) -> None:
1399 """Initialize a StackedConfig.
1401 Args:
1402 backends: List of config files to read from (in order of precedence)
1403 writable: Optional config file to write changes to
1404 """
1405 self.backends = backends
1406 self.writable = writable
1408 def __repr__(self) -> str:
1409 """Return string representation of StackedConfig."""
1410 return f"<{self.__class__.__name__} for {self.backends!r}>"
1412 @classmethod
1413 def default(cls) -> "StackedConfig":
1414 """Create a StackedConfig with default system/user config files.
1416 Returns:
1417 StackedConfig with default configuration files loaded
1418 """
1419 return cls(cls.default_backends())
1421 @classmethod
1422 def default_backends(cls) -> list[ConfigFile]:
1423 """Retrieve the default configuration.
1425 See git-config(1) for details on the files searched.
1426 """
1427 paths = []
1429 # Handle GIT_CONFIG_GLOBAL - overrides user config paths
1430 try:
1431 paths.append(os.environ["GIT_CONFIG_GLOBAL"])
1432 except KeyError:
1433 paths.append(os.path.expanduser("~/.gitconfig"))
1434 paths.append(get_xdg_config_home_path("git", "config"))
1436 # Handle GIT_CONFIG_SYSTEM and GIT_CONFIG_NOSYSTEM
1437 try:
1438 paths.append(os.environ["GIT_CONFIG_SYSTEM"])
1439 except KeyError:
1440 if "GIT_CONFIG_NOSYSTEM" not in os.environ:
1441 paths.append("/etc/gitconfig")
1442 if sys.platform == "win32":
1443 paths.extend(get_win_system_paths())
1445 logger.debug("Loading gitconfig from paths: %s", paths)
1447 backends = []
1448 for path in paths:
1449 try:
1450 cf = ConfigFile.from_path(path)
1451 logger.debug("Successfully loaded gitconfig from: %s", path)
1452 except FileNotFoundError:
1453 logger.debug("Gitconfig file not found: %s", path)
1454 continue
1455 backends.append(cf)
1456 return backends
1458 def get(self, section: SectionLike, name: NameLike) -> Value:
1459 """Get value from configuration."""
1460 if not isinstance(section, tuple):
1461 section = (section,)
1462 for backend in self.backends:
1463 try:
1464 return backend.get(section, name)
1465 except KeyError:
1466 pass
1467 raise KeyError(name)
1469 def get_multivar(self, section: SectionLike, name: NameLike) -> Iterator[Value]:
1470 """Get multiple values from configuration."""
1471 if not isinstance(section, tuple):
1472 section = (section,)
1473 for backend in self.backends:
1474 try:
1475 yield from backend.get_multivar(section, name)
1476 except KeyError:
1477 pass
1479 def set(
1480 self, section: SectionLike, name: NameLike, value: ValueLike | bool
1481 ) -> None:
1482 """Set value in configuration."""
1483 if self.writable is None:
1484 raise NotImplementedError(self.set)
1485 return self.writable.set(section, name, value)
1487 def sections(self) -> Iterator[Section]:
1488 """Get all sections."""
1489 seen = set()
1490 for backend in self.backends:
1491 for section in backend.sections():
1492 if section not in seen:
1493 seen.add(section)
1494 yield section
1497def read_submodules(
1498 path: str | os.PathLike[str],
1499) -> Iterator[tuple[bytes, bytes, bytes]]:
1500 """Read a .gitmodules file."""
1501 cfg = ConfigFile.from_path(path)
1502 return parse_submodules(cfg)
1505def parse_submodules(config: ConfigFile) -> Iterator[tuple[bytes, bytes, bytes]]:
1506 """Parse a gitmodules GitConfig file, returning submodules.
1508 Args:
1509 config: A `ConfigFile`
1510 Returns:
1511 list of tuples (submodule path, url, name),
1512 where name is quoted part of the section's name.
1513 """
1514 for section in config.sections():
1515 section_kind, section_name = section
1516 if section_kind == b"submodule":
1517 try:
1518 sm_path = config.get(section, b"path")
1519 sm_url = config.get(section, b"url")
1520 yield (sm_path, sm_url, section_name)
1521 except KeyError:
1522 # If either path or url is missing, just ignore this
1523 # submodule entry and move on to the next one. This is
1524 # how git itself handles malformed .gitmodule entries.
1525 pass
1528def iter_instead_of(config: Config, push: bool = False) -> Iterable[tuple[str, str]]:
1529 """Iterate over insteadOf / pushInsteadOf values."""
1530 for section in config.sections():
1531 if section[0] != b"url":
1532 continue
1533 replacement = section[1]
1534 try:
1535 needles = list(config.get_multivar(section, "insteadOf"))
1536 except KeyError:
1537 needles = []
1538 if push:
1539 try:
1540 needles += list(config.get_multivar(section, "pushInsteadOf"))
1541 except KeyError:
1542 pass
1543 for needle in needles:
1544 assert isinstance(needle, bytes)
1545 yield needle.decode("utf-8"), replacement.decode("utf-8")
1548def apply_instead_of(config: Config, orig_url: str, push: bool = False) -> str:
1549 """Apply insteadOf / pushInsteadOf to a URL."""
1550 longest_needle = ""
1551 updated_url = orig_url
1552 for needle, replacement in iter_instead_of(config, push):
1553 if not orig_url.startswith(needle):
1554 continue
1555 if len(longest_needle) < len(needle):
1556 longest_needle = needle
1557 updated_url = replacement + orig_url[len(needle) :]
1558 return updated_url