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