Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/dulwich/config.py: 57%
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"""
28__all__ = [
29 "DEFAULT_MAX_INCLUDE_DEPTH",
30 "MAX_INCLUDE_FILE_SIZE",
31 "CaseInsensitiveOrderedMultiDict",
32 "ConditionMatcher",
33 "Config",
34 "ConfigDict",
35 "ConfigFile",
36 "ConfigKey",
37 "ConfigValue",
38 "FileOpener",
39 "StackedConfig",
40 "apply_instead_of",
41 "env_config",
42 "get_win_legacy_system_paths",
43 "get_win_system_paths",
44 "get_xdg_config_home_path",
45 "iter_instead_of",
46 "lower_key",
47 "match_glob_pattern",
48 "parse_submodules",
49 "read_submodules",
50]
52import logging
53import os
54import re
55import sys
56from collections.abc import (
57 Callable,
58 ItemsView,
59 Iterable,
60 Iterator,
61 KeysView,
62 Mapping,
63 MutableMapping,
64 ValuesView,
65)
66from contextlib import suppress
67from pathlib import Path
68from typing import (
69 IO,
70 Generic,
71 TypeVar,
72 overload,
73)
75from .file import GitFile, _GitFile
77ConfigKey = str | bytes | tuple[str | bytes, ...]
78ConfigValue = str | bytes | bool | int
80logger = logging.getLogger(__name__)
82# Type for file opener callback
83FileOpener = Callable[[str | os.PathLike[str]], IO[bytes]]
85# Type for includeIf condition matcher
86# Takes the condition value (e.g., "main" for onbranch:main) and returns bool
87ConditionMatcher = Callable[[str], bool]
89# Security limits for include files
90MAX_INCLUDE_FILE_SIZE = 1024 * 1024 # 1MB max for included config files
91DEFAULT_MAX_INCLUDE_DEPTH = 10 # Maximum recursion depth for includes
94def _match_gitdir_pattern(
95 path: bytes, pattern: bytes, ignorecase: bool = False
96) -> bool:
97 """Simple gitdir pattern matching for includeIf conditions.
99 This handles the basic gitdir patterns used in includeIf directives.
100 """
101 # Convert to strings for easier manipulation
102 path_str = path.decode("utf-8", errors="replace")
103 pattern_str = pattern.decode("utf-8", errors="replace")
105 # Normalize paths to use forward slashes for consistent matching
106 path_str = path_str.replace("\\", "/")
107 pattern_str = pattern_str.replace("\\", "/")
109 if ignorecase:
110 path_str = path_str.lower()
111 pattern_str = pattern_str.lower()
113 # Handle the common cases for gitdir patterns
114 if pattern_str.startswith("**/") and pattern_str.endswith("/**"):
115 # Pattern like **/dirname/** should match any path containing dirname
116 dirname = pattern_str[3:-3] # Remove **/ and /**
117 # Check if path contains the directory name as a path component
118 return ("/" + dirname + "/") in path_str or path_str.endswith("/" + dirname)
119 elif pattern_str.startswith("**/"):
120 # Pattern like **/filename
121 suffix = pattern_str[3:] # Remove **/
122 return suffix in path_str or path_str.endswith("/" + suffix)
123 elif pattern_str.endswith("/**"):
124 # Pattern like /path/to/dir/** should match /path/to/dir and any subdirectory
125 base_pattern = pattern_str[:-3] # Remove /**
126 return path_str == base_pattern or path_str.startswith(base_pattern + "/")
127 elif "**" in pattern_str:
128 # Handle patterns with ** in the middle
129 parts = pattern_str.split("**")
130 if len(parts) == 2:
131 prefix, suffix = parts
132 # Path must start with prefix and end with suffix (if any)
133 if prefix and not path_str.startswith(prefix):
134 return False
135 if suffix and not path_str.endswith(suffix):
136 return False
137 return True
139 # Direct match or simple glob pattern
140 if "*" in pattern_str or "?" in pattern_str or "[" in pattern_str:
141 import fnmatch
143 return fnmatch.fnmatch(path_str, pattern_str)
144 else:
145 return path_str == pattern_str
148def match_glob_pattern(value: str, pattern: str) -> bool:
149 r"""Match a value against a glob pattern.
151 Supports simple glob patterns like ``*`` and ``**``.
153 Raises:
154 ValueError: If the pattern is invalid
155 """
156 # Collapse a run of consecutive '*' to at most '**'. Repeated stars are
157 # redundant, and translating each into its own quantifier would emit
158 # adjacent unbounded quantifiers (e.g. '.*.*') that backtrack
159 # catastrophically on non-matching input (ReDoS).
160 pattern = re.sub(r"\*\*+", "**", pattern)
161 # Convert glob pattern to regex
162 pattern_escaped = re.escape(pattern)
163 # Replace escaped \*\* with .* (match anything)
164 pattern_escaped = pattern_escaped.replace(r"\*\*", ".*")
165 # Replace escaped \* with [^/]* (match anything except /)
166 pattern_escaped = pattern_escaped.replace(r"\*", "[^/]*")
167 # Anchor the pattern
168 pattern_regex = f"^{pattern_escaped}$"
170 try:
171 return bool(re.match(pattern_regex, value))
172 except re.error as e:
173 raise ValueError(f"Invalid glob pattern {pattern!r}: {e}")
176def lower_key(key: ConfigKey) -> ConfigKey:
177 """Convert a config key to lowercase, preserving subsection case.
179 Args:
180 key: Configuration key (str, bytes, or tuple)
182 Returns:
183 Key with section names lowercased, subsection names preserved
185 Raises:
186 TypeError: If key is not str, bytes, or tuple
187 """
188 if isinstance(key, bytes | str):
189 return key.lower()
191 if isinstance(key, tuple):
192 # For config sections, only lowercase the section name (first element)
193 # but preserve the case of subsection names (remaining elements)
194 if len(key) > 0:
195 first = key[0]
196 assert isinstance(first, bytes | str)
197 return (first.lower(), *key[1:])
198 return key
200 raise TypeError(key)
203K = TypeVar("K", bound=ConfigKey) # Key type must be ConfigKey
204V = TypeVar("V") # Value type
205_T = TypeVar("_T") # For get() default parameter
208class _UniqueKeysView(KeysView[K]):
209 """KeysView backed by an explicit list of keys (used to deduplicate)."""
211 def __init__(self, keys: list[K]) -> None:
212 self._keys = keys
214 def __contains__(self, key: object) -> bool:
215 return key in self._keys
217 def __iter__(self) -> Iterator[K]:
218 return iter(self._keys)
220 def __len__(self) -> int:
221 return len(self._keys)
224class _OrderedItemsView(ItemsView[K, V]):
225 """Items view backed by the underlying ordered list of a multi-dict."""
227 def __init__(self, mapping: "CaseInsensitiveOrderedMultiDict[K, V]") -> None:
228 self._mapping = mapping
230 def __iter__(self) -> Iterator[tuple[K, V]]:
231 return iter(self._mapping._real)
233 def __len__(self) -> int:
234 return len(self._mapping._real)
236 def __contains__(self, item: object) -> bool:
237 if not isinstance(item, tuple) or len(item) != 2:
238 return False
239 key, value = item
240 return any(k == key and v == value for k, v in self._mapping._real)
243class CaseInsensitiveOrderedMultiDict(MutableMapping[K, V], Generic[K, V]):
244 """A case-insensitive ordered dictionary that can store multiple values per key.
246 This class maintains the order of insertions and allows multiple values
247 for the same key. Keys are compared case-insensitively.
248 """
250 def __init__(self, default_factory: Callable[[], V] | None = None) -> None:
251 """Initialize a CaseInsensitiveOrderedMultiDict.
253 Args:
254 default_factory: Optional factory function for default values
255 """
256 self._real: list[tuple[K, V]] = []
257 self._keyed: dict[ConfigKey, V] = {}
258 self._default_factory = default_factory
260 @classmethod
261 def make(
262 cls,
263 dict_in: "MutableMapping[K, V] | CaseInsensitiveOrderedMultiDict[K, V] | None" = None,
264 default_factory: Callable[[], V] | None = None,
265 ) -> "CaseInsensitiveOrderedMultiDict[K, V]":
266 """Create a CaseInsensitiveOrderedMultiDict from an existing mapping.
268 Args:
269 dict_in: Optional mapping to initialize from
270 default_factory: Optional factory function for default values
272 Returns:
273 New CaseInsensitiveOrderedMultiDict instance
275 Raises:
276 TypeError: If dict_in is not a mapping or None
277 """
278 if isinstance(dict_in, cls):
279 return dict_in
281 out = cls(default_factory=default_factory)
283 if dict_in is None:
284 return out
286 if not isinstance(dict_in, MutableMapping):
287 raise TypeError
289 for key, value in dict_in.items():
290 out[key] = value
292 return out
294 def __len__(self) -> int:
295 """Return the number of unique keys in the dictionary."""
296 return len(self._keyed)
298 def keys(self) -> KeysView[K]:
299 """Return a view of the dictionary's keys."""
300 # Return a view of the original keys (not lowercased)
301 # We need to deduplicate since _real can have duplicates
302 seen = set()
303 unique_keys = []
304 for k, _ in self._real:
305 lower = lower_key(k)
306 if lower not in seen:
307 seen.add(lower)
308 unique_keys.append(k)
309 return _UniqueKeysView(unique_keys)
311 def items(self) -> ItemsView[K, V]:
312 """Return a view of the dictionary's (key, value) pairs in insertion order."""
313 return _OrderedItemsView(self)
315 def __iter__(self) -> Iterator[K]:
316 """Iterate over the dictionary's keys."""
317 # Return iterator over original keys (not lowercased), deduplicated
318 seen = set()
319 for k, _ in self._real:
320 lower = lower_key(k)
321 if lower not in seen:
322 seen.add(lower)
323 yield k
325 def values(self) -> ValuesView[V]:
326 """Return a view of the dictionary's values."""
327 return self._keyed.values()
329 def __setitem__(self, key: K, value: V) -> None:
330 """Set a value for a key, appending to existing values."""
331 self._real.append((key, value))
332 self._keyed[lower_key(key)] = value
334 def set(self, key: K, value: V) -> None:
335 """Set a value for a key, replacing all existing values.
337 Args:
338 key: The key to set
339 value: The value to set
340 """
341 # This method replaces all existing values for the key
342 lower = lower_key(key)
343 self._real = [(k, v) for k, v in self._real if lower_key(k) != lower]
344 self._real.append((key, value))
345 self._keyed[lower] = value
347 def __delitem__(self, key: K) -> None:
348 """Delete all values for a key.
350 Raises:
351 KeyError: If the key is not found
352 """
353 lower_k = lower_key(key)
354 del self._keyed[lower_k]
355 for i, (actual, unused_value) in reversed(list(enumerate(self._real))):
356 if lower_key(actual) == lower_k:
357 del self._real[i]
359 def __getitem__(self, item: K) -> V:
360 """Get the last value for a key.
362 Raises:
363 KeyError: If the key is not found
364 """
365 return self._keyed[lower_key(item)]
367 def get(self, key: K, /, default: V | _T | None = None) -> V | _T | None: # type: ignore[override]
368 """Get the last value for a key, or a default if not found.
370 Args:
371 key: The key to look up
372 default: Default value to return if key not found
374 Returns:
375 The value for the key, or default/default_factory result if not found
376 """
377 try:
378 return self[key]
379 except KeyError:
380 if default is not None:
381 return default
382 elif self._default_factory is not None:
383 return self._default_factory()
384 else:
385 return None
387 def get_all(self, key: K) -> Iterator[V]:
388 """Get all values for a key in insertion order.
390 Args:
391 key: The key to look up
393 Returns:
394 Iterator of all values for the key
395 """
396 lowered_key = lower_key(key)
397 for actual, value in self._real:
398 if lower_key(actual) == lowered_key:
399 yield value
401 def setdefault(self, key: K, default: V | None = None) -> V:
402 """Get value for key, setting it to default if not present.
404 Args:
405 key: The key to look up
406 default: Default value to set if key not found
408 Returns:
409 The existing value or the newly set default
411 Raises:
412 KeyError: If key not found and no default or default_factory
413 """
414 try:
415 return self[key]
416 except KeyError:
417 if default is not None:
418 self[key] = default
419 return default
420 elif self._default_factory is not None:
421 value = self._default_factory()
422 self[key] = value
423 return value
424 else:
425 raise
428Name = bytes
429NameLike = bytes | str
430Section = tuple[bytes, ...]
431SectionLike = bytes | str | tuple[bytes | str, ...]
432Value = bytes
433ValueLike = bytes | str
436class Config:
437 """A Git configuration."""
439 def get(self, section: SectionLike, name: NameLike) -> Value:
440 """Retrieve the contents of a configuration setting.
442 Args:
443 section: Tuple with section name and optional subsection name
444 name: Variable name
445 Returns:
446 Contents of the setting
447 Raises:
448 KeyError: if the value is not set
449 """
450 raise NotImplementedError(self.get)
452 def get_multivar(self, section: SectionLike, name: NameLike) -> Iterator[Value]:
453 """Retrieve the contents of a multivar configuration setting.
455 Args:
456 section: Tuple with section name and optional subsection namee
457 name: Variable name
458 Returns:
459 Contents of the setting as iterable
460 Raises:
461 KeyError: if the value is not set
462 """
463 raise NotImplementedError(self.get_multivar)
465 @overload
466 def get_boolean(
467 self, section: SectionLike, name: NameLike, default: bool
468 ) -> bool: ...
470 @overload
471 def get_boolean(self, section: SectionLike, name: NameLike) -> bool | None: ...
473 def get_boolean(
474 self, section: SectionLike, name: NameLike, default: bool | None = None
475 ) -> bool | None:
476 """Retrieve a configuration setting as boolean.
478 Args:
479 section: Tuple with section name and optional subsection name
480 name: Name of the setting, including section and possible
481 subsection.
482 default: Default value if setting is not found
484 Returns:
485 Contents of the setting
486 """
487 try:
488 value = self.get(section, name)
489 except KeyError:
490 return default
491 if value.lower() == b"true":
492 return True
493 elif value.lower() == b"false":
494 return False
495 raise ValueError(f"not a valid boolean string: {value!r}")
497 def set(
498 self, section: SectionLike, name: NameLike, value: ValueLike | bool
499 ) -> None:
500 """Set a configuration value.
502 Args:
503 section: Tuple with section name and optional subsection namee
504 name: Name of the configuration value, including section
505 and optional subsection
506 value: value of the setting
507 """
508 raise NotImplementedError(self.set)
510 def items(self, section: SectionLike) -> Iterator[tuple[Name, Value]]:
511 """Iterate over the configuration pairs for a specific section.
513 Args:
514 section: Tuple with section name and optional subsection namee
515 Returns:
516 Iterator over (name, value) pairs
517 """
518 raise NotImplementedError(self.items)
520 def sections(self) -> Iterator[Section]:
521 """Iterate over the sections.
523 Returns: Iterator over section tuples
524 """
525 raise NotImplementedError(self.sections)
527 def has_section(self, name: Section) -> bool:
528 """Check if a specified section exists.
530 Args:
531 name: Name of section to check for
532 Returns:
533 boolean indicating whether the section exists
534 """
535 return name in self.sections()
538class ConfigDict(Config):
539 """Git configuration stored in a dictionary."""
541 def __init__(
542 self,
543 values: MutableMapping[Section, CaseInsensitiveOrderedMultiDict[Name, Value]]
544 | None = None,
545 encoding: str | None = None,
546 ) -> None:
547 """Create a new ConfigDict."""
548 if encoding is None:
549 encoding = sys.getdefaultencoding()
550 self.encoding = encoding
551 self._values: CaseInsensitiveOrderedMultiDict[
552 Section, CaseInsensitiveOrderedMultiDict[Name, Value]
553 ] = CaseInsensitiveOrderedMultiDict.make(
554 values, default_factory=CaseInsensitiveOrderedMultiDict
555 )
557 def __repr__(self) -> str:
558 """Return string representation of ConfigDict."""
559 return f"{self.__class__.__name__}({self._values!r})"
561 def __eq__(self, other: object) -> bool:
562 """Check equality with another ConfigDict."""
563 return isinstance(other, self.__class__) and other._values == self._values
565 def __getitem__(self, key: Section) -> CaseInsensitiveOrderedMultiDict[Name, Value]:
566 """Get configuration values for a section.
568 Raises:
569 KeyError: If section not found
570 """
571 return self._values.__getitem__(key)
573 def __setitem__(
574 self, key: Section, value: CaseInsensitiveOrderedMultiDict[Name, Value]
575 ) -> None:
576 """Set configuration values for a section."""
577 return self._values.__setitem__(key, value)
579 def __delitem__(self, key: Section) -> None:
580 """Delete a configuration section.
582 Raises:
583 KeyError: If section not found
584 """
585 return self._values.__delitem__(key)
587 def __iter__(self) -> Iterator[Section]:
588 """Iterate over configuration sections."""
589 return self._values.__iter__()
591 def __len__(self) -> int:
592 """Return the number of sections."""
593 return self._values.__len__()
595 def keys(self) -> KeysView[Section]:
596 """Return a view of section names."""
597 return self._values.keys()
599 @classmethod
600 def _parse_setting(cls, name: str) -> tuple[str, str | None, str]:
601 parts = name.split(".")
602 if len(parts) == 3:
603 return (parts[0], parts[1], parts[2])
604 else:
605 return (parts[0], None, parts[1])
607 def _check_section_and_name(
608 self, section: SectionLike, name: NameLike
609 ) -> tuple[Section, Name]:
610 if not isinstance(section, tuple):
611 section = (section,)
613 checked_section = tuple(
614 [
615 subsection.encode(self.encoding)
616 if not isinstance(subsection, bytes)
617 else subsection
618 for subsection in section
619 ]
620 )
622 if not isinstance(name, bytes):
623 name = name.encode(self.encoding)
625 return checked_section, name
627 def get_multivar(self, section: SectionLike, name: NameLike) -> Iterator[Value]:
628 """Get multiple values for a configuration setting.
630 Args:
631 section: Section name
632 name: Setting name
634 Returns:
635 Iterator of configuration values
636 """
637 section, name = self._check_section_and_name(section, name)
638 assert len(section) >= 1
640 if len(section) > 1:
641 try:
642 return self._values[section].get_all(name)
643 except KeyError:
644 pass
646 return self._values[(section[0],)].get_all(name)
648 def get(
649 self,
650 section: SectionLike,
651 name: NameLike,
652 ) -> Value:
653 """Get a configuration value.
655 Args:
656 section: Section name
657 name: Setting name
659 Returns:
660 Configuration value
662 Raises:
663 KeyError: if the value is not set
664 """
665 section, name = self._check_section_and_name(section, name)
666 assert len(section) >= 1
668 if len(section) > 1:
669 try:
670 return self._values[section][name]
671 except KeyError:
672 pass
674 return self._values[(section[0],)][name]
676 def set(
677 self,
678 section: SectionLike,
679 name: NameLike,
680 value: ValueLike | bool,
681 ) -> None:
682 """Set a configuration value.
684 Args:
685 section: Section name
686 name: Setting name
687 value: Configuration value
688 """
689 section, name = self._check_section_and_name(section, name)
691 if isinstance(value, bool):
692 value = b"true" if value else b"false"
694 if not isinstance(value, bytes):
695 value = value.encode(self.encoding)
697 section_dict = self._values.setdefault(section)
698 if hasattr(section_dict, "set"):
699 section_dict.set(name, value)
700 else:
701 section_dict[name] = value
703 def add(
704 self,
705 section: SectionLike,
706 name: NameLike,
707 value: ValueLike | bool,
708 ) -> None:
709 """Add a value to a configuration setting, creating a multivar if needed."""
710 section, name = self._check_section_and_name(section, name)
712 if isinstance(value, bool):
713 value = b"true" if value else b"false"
715 if not isinstance(value, bytes):
716 value = value.encode(self.encoding)
718 self._values.setdefault(section)[name] = value
720 def remove(self, section: SectionLike, name: NameLike) -> None:
721 """Remove a configuration setting.
723 Args:
724 section: Section name
725 name: Setting name
727 Raises:
728 KeyError: If the section or name doesn't exist
729 """
730 section, name = self._check_section_and_name(section, name)
731 del self._values[section][name]
733 def items(self, section: SectionLike) -> Iterator[tuple[Name, Value]]:
734 """Get items in a section."""
735 section_bytes, _ = self._check_section_and_name(section, b"")
736 section_dict = self._values.get(section_bytes)
737 if section_dict is not None:
738 return iter(section_dict.items())
739 return iter([])
741 def sections(self) -> Iterator[Section]:
742 """Get all sections."""
743 return iter(self._values.keys())
746def _format_string(value: bytes) -> bytes:
747 if (
748 value.startswith((b" ", b"\t"))
749 or value.endswith((b" ", b"\t"))
750 or b"#" in value
751 ):
752 return b'"' + _escape_value(value) + b'"'
753 else:
754 return _escape_value(value)
757_ESCAPE_TABLE = {
758 ord(b"\\"): ord(b"\\"),
759 ord(b'"'): ord(b'"'),
760 ord(b"n"): ord(b"\n"),
761 ord(b"t"): ord(b"\t"),
762 ord(b"b"): ord(b"\b"),
763}
764_COMMENT_CHARS = [ord(b"#"), ord(b";")]
765_WHITESPACE_CHARS = [ord(b"\t"), ord(b" ")]
768def _parse_string(value: bytes) -> bytes:
769 value_array = bytearray(value.strip())
770 ret = bytearray()
771 whitespace = bytearray()
772 in_quotes = False
773 i = 0
774 while i < len(value_array):
775 c = value_array[i]
776 if c == ord(b"\\"):
777 i += 1
778 if i >= len(value_array):
779 # Backslash at end of string - treat as literal backslash
780 if whitespace:
781 ret.extend(whitespace)
782 whitespace = bytearray()
783 ret.append(ord(b"\\"))
784 else:
785 try:
786 v = _ESCAPE_TABLE[value_array[i]]
787 if whitespace:
788 ret.extend(whitespace)
789 whitespace = bytearray()
790 ret.append(v)
791 except KeyError:
792 # Unknown escape sequence - treat backslash as literal and process next char normally
793 if whitespace:
794 ret.extend(whitespace)
795 whitespace = bytearray()
796 ret.append(ord(b"\\"))
797 i -= 1 # Reprocess the character after the backslash
798 elif c == ord(b'"'):
799 in_quotes = not in_quotes
800 elif c in _COMMENT_CHARS and not in_quotes:
801 # the rest of the line is a comment
802 break
803 elif c in _WHITESPACE_CHARS:
804 if in_quotes:
805 ret.append(c)
806 else:
807 whitespace.append(c)
808 else:
809 if whitespace:
810 ret.extend(whitespace)
811 whitespace = bytearray()
812 ret.append(c)
813 i += 1
815 if in_quotes:
816 raise ValueError("missing end quote")
818 return bytes(ret)
821def _escape_value(value: bytes) -> bytes:
822 """Escape a value."""
823 value = value.replace(b"\\", b"\\\\")
824 value = value.replace(b"\r", b"\\r")
825 value = value.replace(b"\n", b"\\n")
826 value = value.replace(b"\t", b"\\t")
827 value = value.replace(b'"', b'\\"')
828 return value
831def _escape_subsection(name: bytes) -> bytes:
832 r"""Escape a subsection name for writing in a section header.
834 Per git-config: inside the quoted subsection name, only ``"`` and ``\``
835 need (and may) be escaped; newline and NUL are not permitted at all.
836 """
837 if b"\n" in name or b"\0" in name:
838 raise ValueError(f"subsection name {name!r} contains a forbidden character")
839 return name.replace(b"\\", b"\\\\").replace(b'"', b'\\"')
842def _unescape_subsection(name: bytes) -> bytes:
843 r"""Unescape a quoted subsection name read from a section header.
845 Per git-config, ``\"`` and ``\\`` are the only recognised escapes.
846 Git silently drops the backslash on any other ``\x`` sequence, so we
847 match that lenient behaviour to stay compatible with config files
848 written by git or by hand (notably ``includeIf`` headers containing
849 Windows paths where backslashes were not doubled).
850 """
851 out = bytearray()
852 i = 0
853 while i < len(name):
854 c = name[i : i + 1]
855 if c == b"\\" and i + 1 < len(name):
856 out += name[i + 1 : i + 2]
857 i += 2
858 else:
859 out += c
860 i += 1
861 return bytes(out)
864def _check_variable_name(name: bytes) -> bool:
865 for i in range(len(name)):
866 c = name[i : i + 1]
867 if not c.isalnum() and c != b"-":
868 return False
869 return True
872def _check_section_name(name: bytes) -> bool:
873 for i in range(len(name)):
874 c = name[i : i + 1]
875 if not c.isalnum() and c not in (b"-", b"."):
876 return False
877 return True
880def _strip_comments(line: bytes) -> bytes:
881 comment_bytes = {ord(b"#"), ord(b";")}
882 quote = ord(b'"')
883 string_open = False
884 # Normalize line to bytearray for simple 2/3 compatibility
885 for i, character in enumerate(bytearray(line)):
886 # Comment characters outside balanced quotes denote comment start
887 if character == quote:
888 string_open = not string_open
889 elif not string_open and character in comment_bytes:
890 return line[:i]
891 return line
894def _is_line_continuation(value: bytes) -> bool:
895 """Check if a value ends with a line continuation backslash.
897 A line continuation occurs when a line ends with a backslash that is:
898 1. Not escaped (not preceded by another backslash)
899 2. Not within quotes
901 Args:
902 value: The value to check
904 Returns:
905 True if the value ends with a line continuation backslash
906 """
907 if not value.endswith((b"\\\n", b"\\\r\n")):
908 return False
910 # Remove only the newline characters, keep the content including the backslash
911 if value.endswith(b"\\\r\n"):
912 content = value[:-2] # Remove \r\n, keep the \
913 else:
914 content = value[:-1] # Remove \n, keep the \
916 if not content.endswith(b"\\"):
917 return False
919 # Count consecutive backslashes at the end
920 backslash_count = 0
921 for i in range(len(content) - 1, -1, -1):
922 if content[i : i + 1] == b"\\":
923 backslash_count += 1
924 else:
925 break
927 # If we have an odd number of backslashes, the last one is a line continuation
928 # If we have an even number, they are all escaped and there's no continuation
929 return backslash_count % 2 == 1
932def _parse_section_header_line(line: bytes) -> tuple[Section, bytes]:
933 # Parse section header ("[bla]")
934 line = _strip_comments(line).rstrip()
935 in_quotes = False
936 escaped = False
937 for i, c in enumerate(line):
938 if escaped:
939 escaped = False
940 continue
941 if c == ord(b'"'):
942 in_quotes = not in_quotes
943 if c == ord(b"\\"):
944 escaped = True
945 if c == ord(b"]") and not in_quotes:
946 last = i
947 break
948 else:
949 raise ValueError("expected trailing ]")
950 pts = line[1:last].split(b" ", 1)
951 line = line[last + 1 :]
952 section: Section
953 if len(pts) == 2:
954 # Handle subsections - Git allows more complex syntax for certain sections like includeIf
955 if pts[1][:1] == b'"' and pts[1][-1:] == b'"':
956 # Standard quoted subsection
957 pts[1] = _unescape_subsection(pts[1][1:-1])
958 elif pts[0] == b"includeIf":
959 # Special handling for includeIf sections which can have complex conditions
960 # Git allows these without strict quote validation
961 pts[1] = pts[1].strip()
962 if pts[1][:1] == b'"' and pts[1][-1:] == b'"':
963 pts[1] = _unescape_subsection(pts[1][1:-1])
964 else:
965 # Other sections must have quoted subsections
966 raise ValueError(f"Invalid subsection {pts[1]!r}")
967 if not _check_section_name(pts[0]):
968 raise ValueError(f"invalid section name {pts[0]!r}")
969 section = (pts[0], pts[1])
970 else:
971 if not _check_section_name(pts[0]):
972 raise ValueError(f"invalid section name {pts[0]!r}")
973 pts = pts[0].split(b".", 1)
974 if len(pts) == 2:
975 section = (pts[0], pts[1])
976 else:
977 section = (pts[0],)
978 return section, line
981class ConfigFile(ConfigDict):
982 """A Git configuration file, like .git/config or ~/.gitconfig."""
984 def __init__(
985 self,
986 values: MutableMapping[Section, CaseInsensitiveOrderedMultiDict[Name, Value]]
987 | None = None,
988 encoding: str | None = None,
989 ) -> None:
990 """Initialize a ConfigFile.
992 Args:
993 values: Optional mapping of configuration values
994 encoding: Optional encoding for the file (defaults to system encoding)
995 """
996 super().__init__(values=values, encoding=encoding)
997 self.path: str | None = None
998 self._included_paths: set[str] = set() # Track included files to prevent cycles
1000 @classmethod
1001 def from_file(
1002 cls,
1003 f: IO[bytes],
1004 *,
1005 config_dir: str | None = None,
1006 included_paths: set[str] | None = None,
1007 include_depth: int = 0,
1008 max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH,
1009 file_opener: FileOpener | None = None,
1010 condition_matchers: Mapping[str, ConditionMatcher] | None = None,
1011 expand_includes: bool = True,
1012 ) -> "ConfigFile":
1013 """Read configuration from a file-like object.
1015 Args:
1016 f: File-like object to read from
1017 config_dir: Directory containing the config file (for relative includes)
1018 included_paths: Set of already included paths (to prevent cycles)
1019 include_depth: Current include depth (to prevent infinite recursion)
1020 max_include_depth: Maximum allowed include depth
1021 file_opener: Optional callback to open included files
1022 condition_matchers: Optional dict of condition matchers for includeIf
1023 expand_includes: Whether to honor include/includeIf directives. Set
1024 to False when parsing untrusted config (e.g. .gitmodules) to
1025 avoid following include.path to arbitrary files.
1026 """
1027 if include_depth > max_include_depth:
1028 # Prevent excessive recursion
1029 raise ValueError(f"Maximum include depth ({max_include_depth}) exceeded")
1031 ret = cls()
1032 if included_paths is not None:
1033 ret._included_paths = included_paths.copy()
1035 section: Section | None = None
1036 setting = None
1037 continuation = None
1038 for lineno, line in enumerate(f.readlines()):
1039 if lineno == 0 and line.startswith(b"\xef\xbb\xbf"):
1040 line = line[3:]
1041 line = line.lstrip()
1042 if setting is None:
1043 if len(line) > 0 and line[:1] == b"[":
1044 section, line = _parse_section_header_line(line)
1045 ret._values.setdefault(section)
1046 if _strip_comments(line).strip() == b"":
1047 continue
1048 if section is None:
1049 raise ValueError(f"setting {line!r} without section")
1050 try:
1051 setting, value = line.split(b"=", 1)
1052 except ValueError:
1053 setting = line
1054 value = b"true"
1055 setting = setting.strip()
1056 if not _check_variable_name(setting):
1057 raise ValueError(f"invalid variable name {setting!r}")
1058 if _is_line_continuation(value):
1059 if value.endswith(b"\\\r\n"):
1060 continuation = value[:-3]
1061 else:
1062 continuation = value[:-2]
1063 else:
1064 continuation = None
1065 value = _parse_string(value)
1066 ret._values[section][setting] = value
1068 # Process include/includeIf directives
1069 if expand_includes:
1070 ret._handle_include_directive(
1071 section,
1072 setting,
1073 value,
1074 config_dir=config_dir,
1075 include_depth=include_depth,
1076 max_include_depth=max_include_depth,
1077 file_opener=file_opener,
1078 condition_matchers=condition_matchers,
1079 )
1081 setting = None
1082 else: # continuation line
1083 assert continuation is not None
1084 if _is_line_continuation(line):
1085 if line.endswith(b"\\\r\n"):
1086 continuation += line[:-3]
1087 else:
1088 continuation += line[:-2]
1089 else:
1090 continuation += line
1091 value = _parse_string(continuation)
1092 assert section is not None # Already checked above
1093 ret._values[section][setting] = value
1095 # Process include/includeIf directives
1096 if expand_includes:
1097 ret._handle_include_directive(
1098 section,
1099 setting,
1100 value,
1101 config_dir=config_dir,
1102 include_depth=include_depth,
1103 max_include_depth=max_include_depth,
1104 file_opener=file_opener,
1105 condition_matchers=condition_matchers,
1106 )
1108 continuation = None
1109 setting = None
1110 return ret
1112 def _handle_include_directive(
1113 self,
1114 section: Section | None,
1115 setting: bytes,
1116 value: bytes,
1117 *,
1118 config_dir: str | None,
1119 include_depth: int,
1120 max_include_depth: int,
1121 file_opener: FileOpener | None,
1122 condition_matchers: Mapping[str, ConditionMatcher] | None,
1123 ) -> None:
1124 """Handle include/includeIf directives during config parsing."""
1125 if (
1126 section is not None
1127 and setting == b"path"
1128 and (
1129 section[0].lower() == b"include"
1130 or (len(section) > 1 and section[0].lower() == b"includeif")
1131 )
1132 ):
1133 self._process_include(
1134 section,
1135 value,
1136 config_dir=config_dir,
1137 include_depth=include_depth,
1138 max_include_depth=max_include_depth,
1139 file_opener=file_opener,
1140 condition_matchers=condition_matchers,
1141 )
1143 def _process_include(
1144 self,
1145 section: Section,
1146 path_value: bytes,
1147 *,
1148 config_dir: str | None,
1149 include_depth: int,
1150 max_include_depth: int,
1151 file_opener: FileOpener | None,
1152 condition_matchers: Mapping[str, ConditionMatcher] | None,
1153 ) -> None:
1154 """Process an include or includeIf directive."""
1155 path_str = path_value.decode(self.encoding, errors="replace")
1157 # Handle includeIf conditions
1158 if len(section) > 1 and section[0].lower() == b"includeif":
1159 condition = section[1].decode(self.encoding, errors="replace")
1160 if not self._evaluate_includeif_condition(
1161 condition, config_dir, condition_matchers
1162 ):
1163 return
1165 # Resolve the include path
1166 include_path = self._resolve_include_path(path_str, config_dir)
1167 if not include_path:
1168 return
1170 # Check for circular includes
1171 try:
1172 abs_path = str(Path(include_path).resolve())
1173 except (OSError, ValueError) as e:
1174 # Invalid path - log and skip
1175 logger.debug("Invalid include path %r: %s", include_path, e)
1176 return
1177 if abs_path in self._included_paths:
1178 return
1180 # Load and merge the included file
1181 try:
1182 # Use provided file opener or default to GitFile
1183 opener: FileOpener = (
1184 file_opener if file_opener is not None else lambda p: GitFile(p, "rb")
1185 )
1187 f = opener(include_path)
1188 except (OSError, ValueError) as e:
1189 # Git silently ignores missing or unreadable include files
1190 # Log for debugging purposes
1191 logger.debug("Invalid include path %r: %s", include_path, e)
1192 else:
1193 with f as included_file:
1194 # Track this path to prevent cycles
1195 self._included_paths.add(abs_path)
1197 # Parse the included file
1198 included_config = ConfigFile.from_file(
1199 included_file,
1200 config_dir=os.path.dirname(include_path),
1201 included_paths=self._included_paths,
1202 include_depth=include_depth + 1,
1203 max_include_depth=max_include_depth,
1204 file_opener=file_opener,
1205 condition_matchers=condition_matchers,
1206 )
1208 # Merge the included configuration
1209 self._merge_config(included_config)
1211 def _merge_config(self, other: "ConfigFile") -> None:
1212 """Merge another config file into this one."""
1213 for section, values in other._values.items():
1214 if section not in self._values:
1215 self._values[section] = CaseInsensitiveOrderedMultiDict()
1216 for key, value in values.items():
1217 self._values[section][key] = value
1219 def _resolve_include_path(self, path: str, config_dir: str | None) -> str | None:
1220 """Resolve an include path to an absolute path."""
1221 # Expand ~ to home directory
1222 path = os.path.expanduser(path)
1224 # If path is relative and we have a config directory, make it relative to that
1225 if not os.path.isabs(path) and config_dir:
1226 path = os.path.join(config_dir, path)
1228 return path
1230 def _evaluate_includeif_condition(
1231 self,
1232 condition: str,
1233 config_dir: str | None = None,
1234 condition_matchers: Mapping[str, ConditionMatcher] | None = None,
1235 ) -> bool:
1236 """Evaluate an includeIf condition."""
1237 # Try custom matchers first if provided
1238 if condition_matchers:
1239 for prefix, matcher in condition_matchers.items():
1240 if condition.startswith(prefix):
1241 return matcher(condition[len(prefix) :])
1243 # Fall back to built-in matchers
1244 if condition.startswith("hasconfig:"):
1245 return self._evaluate_hasconfig_condition(condition[10:])
1246 else:
1247 # Unknown condition type - log and ignore (Git behavior)
1248 logger.debug("Unknown includeIf condition: %r", condition)
1249 return False
1251 def _evaluate_hasconfig_condition(self, condition: str) -> bool:
1252 """Evaluate a hasconfig condition.
1254 Format: hasconfig:config.key:pattern
1255 Example: hasconfig:remote.*.url:ssh://org-*@github.com/**
1256 """
1257 # Split on the first colon to separate config key from pattern
1258 parts = condition.split(":", 1)
1259 if len(parts) != 2:
1260 logger.debug("Invalid hasconfig condition format: %r", condition)
1261 return False
1263 config_key, pattern = parts
1265 # Parse the config key to get section and name
1266 key_parts = config_key.split(".", 2)
1267 if len(key_parts) < 2:
1268 logger.debug("Invalid hasconfig config key: %r", config_key)
1269 return False
1271 # Handle wildcards in section names (e.g., remote.*)
1272 if len(key_parts) == 3 and key_parts[1] == "*":
1273 # Match any subsection
1274 section_prefix = key_parts[0].encode(self.encoding)
1275 name = key_parts[2].encode(self.encoding)
1277 # Check all sections that match the pattern
1278 for section in self.sections():
1279 if len(section) == 2 and section[0] == section_prefix:
1280 try:
1281 values = list(self.get_multivar(section, name))
1282 for value in values:
1283 if self._match_hasconfig_pattern(value, pattern):
1284 return True
1285 except KeyError:
1286 continue
1287 else:
1288 # Direct section lookup
1289 if len(key_parts) == 2:
1290 section = (key_parts[0].encode(self.encoding),)
1291 name = key_parts[1].encode(self.encoding)
1292 else:
1293 section = (
1294 key_parts[0].encode(self.encoding),
1295 key_parts[1].encode(self.encoding),
1296 )
1297 name = key_parts[2].encode(self.encoding)
1299 try:
1300 values = list(self.get_multivar(section, name))
1301 for value in values:
1302 if self._match_hasconfig_pattern(value, pattern):
1303 return True
1304 except KeyError:
1305 pass
1307 return False
1309 def _match_hasconfig_pattern(self, value: bytes, pattern: str) -> bool:
1310 """Match a config value against a hasconfig pattern.
1312 Supports simple glob patterns like ``*`` and ``**``.
1313 """
1314 value_str = value.decode(self.encoding, errors="replace")
1315 return match_glob_pattern(value_str, pattern)
1317 @classmethod
1318 def from_path(
1319 cls,
1320 path: str | os.PathLike[str],
1321 *,
1322 max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH,
1323 file_opener: FileOpener | None = None,
1324 condition_matchers: Mapping[str, ConditionMatcher] | None = None,
1325 expand_includes: bool = True,
1326 ) -> "ConfigFile":
1327 """Read configuration from a file on disk.
1329 Args:
1330 path: Path to the configuration file
1331 max_include_depth: Maximum allowed include depth
1332 file_opener: Optional callback to open included files
1333 condition_matchers: Optional dict of condition matchers for includeIf
1334 expand_includes: Whether to honor include/includeIf directives. Set
1335 to False when parsing untrusted config (e.g. .gitmodules) to
1336 avoid following include.path to arbitrary files.
1337 """
1338 abs_path = os.fspath(path)
1339 config_dir = os.path.dirname(abs_path)
1341 # Use provided file opener or default to GitFile
1342 opener: FileOpener = (
1343 file_opener if file_opener is not None else lambda p: GitFile(p, "rb")
1344 )
1346 with opener(abs_path) as f:
1347 ret = cls.from_file(
1348 f,
1349 config_dir=config_dir,
1350 max_include_depth=max_include_depth,
1351 file_opener=file_opener,
1352 condition_matchers=condition_matchers,
1353 expand_includes=expand_includes,
1354 )
1355 ret.path = abs_path
1356 return ret
1358 def write_to_path(self, path: str | os.PathLike[str] | None = None) -> None:
1359 """Write configuration to a file on disk."""
1360 if path is None:
1361 if self.path is None:
1362 raise ValueError("No path specified and no default path available")
1363 path_to_use: str | os.PathLike[str] = self.path
1364 else:
1365 path_to_use = path
1366 with GitFile(path_to_use, "wb") as f:
1367 self.write_to_file(f)
1369 def write_to_file(self, f: IO[bytes] | _GitFile) -> None:
1370 """Write configuration to a file-like object."""
1371 for section, values in self._values.items():
1372 try:
1373 section_name, subsection_name = section
1374 except ValueError:
1375 (section_name,) = section
1376 subsection_name = None
1377 if subsection_name is None:
1378 f.write(b"[" + section_name + b"]\n")
1379 else:
1380 f.write(
1381 b"["
1382 + section_name
1383 + b' "'
1384 + _escape_subsection(subsection_name)
1385 + b'"]\n'
1386 )
1387 for key, value in values.items():
1388 value = _format_string(value)
1389 f.write(b"\t" + key + b" = " + value + b"\n")
1392def get_xdg_config_home_path(*path_segments: str) -> str:
1393 """Get a path in the XDG config home directory.
1395 Args:
1396 *path_segments: Path segments to join to the XDG config home
1398 Returns:
1399 Full path in XDG config home directory
1400 """
1401 xdg_config_home = os.environ.get(
1402 "XDG_CONFIG_HOME",
1403 os.path.expanduser("~/.config/"),
1404 )
1405 return os.path.join(xdg_config_home, *path_segments)
1408def _find_git_in_win_path() -> Iterator[str]:
1409 for exe in ("git.exe", "git.cmd"):
1410 for path in os.environ.get("PATH", "").split(";"):
1411 if os.path.exists(os.path.join(path, exe)):
1412 # in windows native shells (powershell/cmd) exe path is
1413 # .../Git/bin/git.exe or .../Git/cmd/git.exe
1414 #
1415 # in git-bash exe path is .../Git/mingw64/bin/git.exe
1416 git_dir, _bin_dir = os.path.split(path)
1417 yield git_dir
1418 parent_dir, basename = os.path.split(git_dir)
1419 if basename == "mingw32" or basename == "mingw64":
1420 yield parent_dir
1421 break
1424def _find_git_in_win_reg() -> Iterator[str]:
1425 import platform
1426 import winreg
1428 if platform.machine() == "AMD64":
1429 subkey = (
1430 "SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\"
1431 "CurrentVersion\\Uninstall\\Git_is1"
1432 )
1433 else:
1434 subkey = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Git_is1"
1436 for key in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE): # type: ignore[attr-defined,unused-ignore]
1437 with suppress(OSError):
1438 with winreg.OpenKey(key, subkey) as k: # type: ignore[attr-defined,unused-ignore]
1439 val, typ = winreg.QueryValueEx(k, "InstallLocation") # type: ignore[attr-defined,unused-ignore]
1440 if typ == winreg.REG_SZ: # type: ignore[attr-defined,unused-ignore]
1441 yield val
1444# There is no set standard for system config dirs on windows. We try the
1445# following:
1446# - %PROGRAMFILES%/Git/etc/gitconfig - Git for Windows (msysgit) config dir
1447# Used if CGit installation (Git/bin/git.exe) is found in PATH in the
1448# system registry
1449def get_win_system_paths() -> Iterator[str]:
1450 """Get current Windows system Git config paths.
1452 Only returns the current Git for Windows config location, not legacy paths.
1453 """
1454 # Try to find Git installation from PATH first
1455 for git_dir in _find_git_in_win_path():
1456 yield os.path.join(git_dir, "etc", "gitconfig")
1457 return # Only use the first found path
1459 # Fall back to registry if not found in PATH
1460 for git_dir in _find_git_in_win_reg():
1461 yield os.path.join(git_dir, "etc", "gitconfig")
1462 return # Only use the first found path
1465def get_win_legacy_system_paths() -> Iterator[str]:
1466 """Get legacy Windows system Git config paths.
1468 Returns all possible config paths including deprecated locations.
1469 This function can be used for diagnostics or migration purposes.
1470 """
1471 # Include deprecated PROGRAMDATA location
1472 if "PROGRAMDATA" in os.environ:
1473 yield os.path.join(os.environ["PROGRAMDATA"], "Git", "config")
1475 # Include all Git installations found
1476 for git_dir in _find_git_in_win_path():
1477 yield os.path.join(git_dir, "etc", "gitconfig")
1478 for git_dir in _find_git_in_win_reg():
1479 yield os.path.join(git_dir, "etc", "gitconfig")
1482def env_config(
1483 environ: Mapping[str, str],
1484) -> "ConfigFile | None":
1485 """Build a ConfigFile from GIT_CONFIG_COUNT/KEY_n/VALUE_n vars.
1487 See git-config(1). Any missing key/value, a key without a dot, or a
1488 non-numeric or negative ``GIT_CONFIG_COUNT`` is treated as an error.
1489 Callers that want git's "env overrides everything" precedence should
1490 prepend the result to their ``StackedConfig.backends``; nothing in
1491 dulwich consults ``os.environ`` for these variables on its own.
1493 Args:
1494 environ: Mapping to read the variables from (e.g. ``os.environ``).
1496 Returns:
1497 A ConfigFile holding the overrides, or None if GIT_CONFIG_COUNT is
1498 unset or empty (which git treats as zero pairs).
1499 """
1500 raw_count = environ.get("GIT_CONFIG_COUNT")
1501 if raw_count is None or raw_count == "":
1502 return None
1503 try:
1504 count = int(raw_count)
1505 except ValueError:
1506 raise ValueError(f"bogus count in GIT_CONFIG_COUNT: {raw_count!r}") from None
1507 if count < 0:
1508 raise ValueError(f"bogus count in GIT_CONFIG_COUNT: {raw_count!r}")
1510 cf = ConfigFile()
1511 for i in range(count):
1512 key_var = f"GIT_CONFIG_KEY_{i}"
1513 value_var = f"GIT_CONFIG_VALUE_{i}"
1514 try:
1515 key = environ[key_var]
1516 except KeyError:
1517 raise KeyError(f"missing config key {key_var}") from None
1518 try:
1519 value = environ[value_var]
1520 except KeyError:
1521 raise KeyError(f"missing config value {value_var}") from None
1522 if "." not in key:
1523 raise ValueError(f"invalid config format: {key}")
1524 # Git keys are <section>.<name> or <section>.<subsection>.<name>.
1525 # The subsection (if present) may itself contain dots.
1526 first_dot = key.find(".")
1527 last_dot = key.rfind(".")
1528 section_name = key[:first_dot]
1529 name = key[last_dot + 1 :]
1530 if first_dot == last_dot:
1531 section: Section = (section_name.encode("utf-8"),)
1532 else:
1533 subsection = key[first_dot + 1 : last_dot]
1534 section = (
1535 section_name.encode("utf-8"),
1536 subsection.encode("utf-8"),
1537 )
1538 cf.add(section, name.encode("utf-8"), value.encode("utf-8"))
1539 return cf
1542class StackedConfig(Config):
1543 """Configuration which reads from multiple config files.."""
1545 def __init__(
1546 self, backends: list[ConfigFile], writable: ConfigFile | None = None
1547 ) -> None:
1548 """Initialize a StackedConfig.
1550 Args:
1551 backends: List of config files to read from (in order of precedence)
1552 writable: Optional config file to write changes to
1553 """
1554 self.backends = backends
1555 self.writable = writable
1557 def __repr__(self) -> str:
1558 """Return string representation of StackedConfig."""
1559 return f"<{self.__class__.__name__} for {self.backends!r}>"
1561 @classmethod
1562 def default(cls) -> "StackedConfig":
1563 """Create a StackedConfig with default system/user config files.
1565 Returns:
1566 StackedConfig with default configuration files loaded
1567 """
1568 return cls(cls.default_backends())
1570 @classmethod
1571 def default_backends(cls) -> list[ConfigFile]:
1572 """Retrieve the default configuration.
1574 See git-config(1) for details on the files searched.
1575 """
1576 paths = []
1578 # Handle GIT_CONFIG_GLOBAL - overrides user config paths
1579 try:
1580 paths.append(os.environ["GIT_CONFIG_GLOBAL"])
1581 except KeyError:
1582 paths.append(os.path.expanduser("~/.gitconfig"))
1583 paths.append(get_xdg_config_home_path("git", "config"))
1585 # Handle GIT_CONFIG_SYSTEM and GIT_CONFIG_NOSYSTEM
1586 try:
1587 paths.append(os.environ["GIT_CONFIG_SYSTEM"])
1588 except KeyError:
1589 if "GIT_CONFIG_NOSYSTEM" not in os.environ:
1590 paths.append("/etc/gitconfig")
1591 if sys.platform == "win32":
1592 paths.extend(get_win_system_paths())
1594 logger.debug("Loading gitconfig from paths: %s", paths)
1596 backends = []
1597 for path in paths:
1598 try:
1599 cf = ConfigFile.from_path(path)
1600 logger.debug("Successfully loaded gitconfig from: %s", path)
1601 except FileNotFoundError:
1602 logger.debug("Gitconfig file not found: %s", path)
1603 continue
1604 backends.append(cf)
1606 return backends
1608 def get(self, section: SectionLike, name: NameLike) -> Value:
1609 """Get value from configuration."""
1610 if not isinstance(section, tuple):
1611 section = (section,)
1612 for backend in self.backends:
1613 try:
1614 return backend.get(section, name)
1615 except KeyError:
1616 pass
1617 raise KeyError(name)
1619 def get_multivar(self, section: SectionLike, name: NameLike) -> Iterator[Value]:
1620 """Get multiple values from configuration."""
1621 if not isinstance(section, tuple):
1622 section = (section,)
1623 for backend in self.backends:
1624 try:
1625 yield from backend.get_multivar(section, name)
1626 except KeyError:
1627 pass
1629 def set(
1630 self, section: SectionLike, name: NameLike, value: ValueLike | bool
1631 ) -> None:
1632 """Set value in configuration."""
1633 if self.writable is None:
1634 raise NotImplementedError(self.set)
1635 return self.writable.set(section, name, value)
1637 def sections(self) -> Iterator[Section]:
1638 """Get all sections."""
1639 seen = set()
1640 for backend in self.backends:
1641 for section in backend.sections():
1642 if section not in seen:
1643 seen.add(section)
1644 yield section
1647def read_submodules(
1648 path: str | os.PathLike[str],
1649) -> Iterator[tuple[bytes, bytes, bytes]]:
1650 """Read a .gitmodules file."""
1651 # .gitmodules is attacker-controlled in any cloned tree; do not expand
1652 # include/includeIf directives so a hostile file cannot make us open and
1653 # parse arbitrary paths on disk. This matches git, which parses submodule
1654 # config with includes disabled.
1655 cfg = ConfigFile.from_path(path, expand_includes=False)
1656 return parse_submodules(cfg)
1659def parse_submodules(config: ConfigFile) -> Iterator[tuple[bytes, bytes, bytes]]:
1660 """Parse a gitmodules GitConfig file, returning submodules.
1662 Args:
1663 config: A `ConfigFile`
1664 Returns:
1665 list of tuples (submodule path, url, name),
1666 where name is quoted part of the section's name.
1667 """
1668 for section in config.sections():
1669 if len(section) != 2:
1670 # Sections without a subsection name (e.g. a stray [include])
1671 # cannot be submodule entries; skip rather than crash.
1672 continue
1673 section_kind, section_name = section
1674 if section_kind == b"submodule":
1675 try:
1676 sm_path = config.get(section, b"path")
1677 sm_url = config.get(section, b"url")
1678 yield (sm_path, sm_url, section_name)
1679 except KeyError:
1680 # If either path or url is missing, just ignore this
1681 # submodule entry and move on to the next one. This is
1682 # how git itself handles malformed .gitmodule entries.
1683 pass
1686def iter_instead_of(config: Config, push: bool = False) -> Iterable[tuple[str, str]]:
1687 """Iterate over insteadOf / pushInsteadOf values."""
1688 for section in config.sections():
1689 if section[0] != b"url":
1690 continue
1691 replacement = section[1]
1692 try:
1693 needles = list(config.get_multivar(section, "insteadOf"))
1694 except KeyError:
1695 needles = []
1696 if push:
1697 try:
1698 needles += list(config.get_multivar(section, "pushInsteadOf"))
1699 except KeyError:
1700 pass
1701 for needle in needles:
1702 assert isinstance(needle, bytes)
1703 yield needle.decode("utf-8"), replacement.decode("utf-8")
1706def get_git_proxy_command(config: Config, host: str) -> str | None:
1707 """Look up the core.gitProxy command for the given host.
1709 The ``core.gitProxy`` variable can appear multiple times, each with an
1710 optional ``for <domain>`` suffix. The first entry whose domain suffix
1711 matches the end of *host* wins; an entry without a ``for`` clause is a
1712 catch-all default.
1714 Args:
1715 config: A Config instance.
1716 host: The hostname being connected to.
1718 Returns:
1719 The proxy command string, or ``None`` if no proxy is configured.
1720 """
1721 try:
1722 values = list(config.get_multivar((b"core",), b"gitProxy"))
1723 except KeyError:
1724 return None
1726 default_proxy: str | None = None
1727 for raw in values:
1728 text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
1729 # Entries may look like:
1730 # proxy-command for kernel.org
1731 # default-proxy
1732 parts = text.rsplit(" for ", 1)
1733 if len(parts) == 2:
1734 command, domain = parts[0].strip(), parts[1].strip()
1735 if host == domain or host.endswith("." + domain):
1736 return command
1737 else:
1738 default_proxy = text.strip()
1740 return default_proxy
1743def apply_instead_of(config: Config, orig_url: str, push: bool = False) -> str:
1744 """Apply insteadOf / pushInsteadOf to a URL."""
1745 longest_needle = ""
1746 updated_url = orig_url
1747 for needle, replacement in iter_instead_of(config, push):
1748 if not orig_url.startswith(needle):
1749 continue
1750 if len(longest_needle) < len(needle):
1751 longest_needle = needle
1752 updated_url = replacement + orig_url[len(needle) :]
1753 return updated_url