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 # Convert glob pattern to regex
157 pattern_escaped = re.escape(pattern)
158 # Replace escaped \*\* with .* (match anything)
159 pattern_escaped = pattern_escaped.replace(r"\*\*", ".*")
160 # Replace escaped \* with [^/]* (match anything except /)
161 pattern_escaped = pattern_escaped.replace(r"\*", "[^/]*")
162 # Anchor the pattern
163 pattern_regex = f"^{pattern_escaped}$"
165 try:
166 return bool(re.match(pattern_regex, value))
167 except re.error as e:
168 raise ValueError(f"Invalid glob pattern {pattern!r}: {e}")
171def lower_key(key: ConfigKey) -> ConfigKey:
172 """Convert a config key to lowercase, preserving subsection case.
174 Args:
175 key: Configuration key (str, bytes, or tuple)
177 Returns:
178 Key with section names lowercased, subsection names preserved
180 Raises:
181 TypeError: If key is not str, bytes, or tuple
182 """
183 if isinstance(key, bytes | str):
184 return key.lower()
186 if isinstance(key, tuple):
187 # For config sections, only lowercase the section name (first element)
188 # but preserve the case of subsection names (remaining elements)
189 if len(key) > 0:
190 first = key[0]
191 assert isinstance(first, bytes | str)
192 return (first.lower(), *key[1:])
193 return key
195 raise TypeError(key)
198K = TypeVar("K", bound=ConfigKey) # Key type must be ConfigKey
199V = TypeVar("V") # Value type
200_T = TypeVar("_T") # For get() default parameter
203class CaseInsensitiveOrderedMultiDict(MutableMapping[K, V], Generic[K, V]):
204 """A case-insensitive ordered dictionary that can store multiple values per key.
206 This class maintains the order of insertions and allows multiple values
207 for the same key. Keys are compared case-insensitively.
208 """
210 def __init__(self, default_factory: Callable[[], V] | None = None) -> None:
211 """Initialize a CaseInsensitiveOrderedMultiDict.
213 Args:
214 default_factory: Optional factory function for default values
215 """
216 self._real: list[tuple[K, V]] = []
217 self._keyed: dict[ConfigKey, V] = {}
218 self._default_factory = default_factory
220 @classmethod
221 def make(
222 cls,
223 dict_in: "MutableMapping[K, V] | CaseInsensitiveOrderedMultiDict[K, V] | None" = None,
224 default_factory: Callable[[], V] | None = None,
225 ) -> "CaseInsensitiveOrderedMultiDict[K, V]":
226 """Create a CaseInsensitiveOrderedMultiDict from an existing mapping.
228 Args:
229 dict_in: Optional mapping to initialize from
230 default_factory: Optional factory function for default values
232 Returns:
233 New CaseInsensitiveOrderedMultiDict instance
235 Raises:
236 TypeError: If dict_in is not a mapping or None
237 """
238 if isinstance(dict_in, cls):
239 return dict_in
241 out = cls(default_factory=default_factory)
243 if dict_in is None:
244 return out
246 if not isinstance(dict_in, MutableMapping):
247 raise TypeError
249 for key, value in dict_in.items():
250 out[key] = value
252 return out
254 def __len__(self) -> int:
255 """Return the number of unique keys in the dictionary."""
256 return len(self._keyed)
258 def keys(self) -> KeysView[K]:
259 """Return a view of the dictionary's keys."""
260 # Return a view of the original keys (not lowercased)
261 # We need to deduplicate since _real can have duplicates
262 seen = set()
263 unique_keys = []
264 for k, _ in self._real:
265 lower = lower_key(k)
266 if lower not in seen:
267 seen.add(lower)
268 unique_keys.append(k)
269 from collections.abc import KeysView as ABCKeysView
271 class UniqueKeysView(ABCKeysView[K]):
272 def __init__(self, keys: list[K]):
273 self._keys = keys
275 def __contains__(self, key: object) -> bool:
276 return key in self._keys
278 def __iter__(self) -> Iterator[K]:
279 return iter(self._keys)
281 def __len__(self) -> int:
282 return len(self._keys)
284 return UniqueKeysView(unique_keys)
286 def items(self) -> ItemsView[K, V]:
287 """Return a view of the dictionary's (key, value) pairs in insertion order."""
289 # Return a view that iterates over the real list to preserve order
290 class OrderedItemsView(ItemsView[K, V]):
291 """Items view that preserves insertion order."""
293 def __init__(self, mapping: CaseInsensitiveOrderedMultiDict[K, V]):
294 self._mapping = mapping
296 def __iter__(self) -> Iterator[tuple[K, V]]:
297 return iter(self._mapping._real)
299 def __len__(self) -> int:
300 return len(self._mapping._real)
302 def __contains__(self, item: object) -> bool:
303 if not isinstance(item, tuple) or len(item) != 2:
304 return False
305 key, value = item
306 return any(k == key and v == value for k, v in self._mapping._real)
308 return OrderedItemsView(self)
310 def __iter__(self) -> Iterator[K]:
311 """Iterate over the dictionary's keys."""
312 # Return iterator over original keys (not lowercased), deduplicated
313 seen = set()
314 for k, _ in self._real:
315 lower = lower_key(k)
316 if lower not in seen:
317 seen.add(lower)
318 yield k
320 def values(self) -> ValuesView[V]:
321 """Return a view of the dictionary's values."""
322 return self._keyed.values()
324 def __setitem__(self, key: K, value: V) -> None:
325 """Set a value for a key, appending to existing values."""
326 self._real.append((key, value))
327 self._keyed[lower_key(key)] = value
329 def set(self, key: K, value: V) -> None:
330 """Set a value for a key, replacing all existing values.
332 Args:
333 key: The key to set
334 value: The value to set
335 """
336 # This method replaces all existing values for the key
337 lower = lower_key(key)
338 self._real = [(k, v) for k, v in self._real if lower_key(k) != lower]
339 self._real.append((key, value))
340 self._keyed[lower] = value
342 def __delitem__(self, key: K) -> None:
343 """Delete all values for a key.
345 Raises:
346 KeyError: If the key is not found
347 """
348 lower_k = lower_key(key)
349 del self._keyed[lower_k]
350 for i, (actual, unused_value) in reversed(list(enumerate(self._real))):
351 if lower_key(actual) == lower_k:
352 del self._real[i]
354 def __getitem__(self, item: K) -> V:
355 """Get the last value for a key.
357 Raises:
358 KeyError: If the key is not found
359 """
360 return self._keyed[lower_key(item)]
362 def get(self, key: K, /, default: V | _T | None = None) -> V | _T | None: # type: ignore[override]
363 """Get the last value for a key, or a default if not found.
365 Args:
366 key: The key to look up
367 default: Default value to return if key not found
369 Returns:
370 The value for the key, or default/default_factory result if not found
371 """
372 try:
373 return self[key]
374 except KeyError:
375 if default is not None:
376 return default
377 elif self._default_factory is not None:
378 return self._default_factory()
379 else:
380 return None
382 def get_all(self, key: K) -> Iterator[V]:
383 """Get all values for a key in insertion order.
385 Args:
386 key: The key to look up
388 Returns:
389 Iterator of all values for the key
390 """
391 lowered_key = lower_key(key)
392 for actual, value in self._real:
393 if lower_key(actual) == lowered_key:
394 yield value
396 def setdefault(self, key: K, default: V | None = None) -> V:
397 """Get value for key, setting it to default if not present.
399 Args:
400 key: The key to look up
401 default: Default value to set if key not found
403 Returns:
404 The existing value or the newly set default
406 Raises:
407 KeyError: If key not found and no default or default_factory
408 """
409 try:
410 return self[key]
411 except KeyError:
412 if default is not None:
413 self[key] = default
414 return default
415 elif self._default_factory is not None:
416 value = self._default_factory()
417 self[key] = value
418 return value
419 else:
420 raise
423Name = bytes
424NameLike = bytes | str
425Section = tuple[bytes, ...]
426SectionLike = bytes | str | tuple[bytes | str, ...]
427Value = bytes
428ValueLike = bytes | str
431class Config:
432 """A Git configuration."""
434 def get(self, section: SectionLike, name: NameLike) -> Value:
435 """Retrieve the contents of a configuration setting.
437 Args:
438 section: Tuple with section name and optional subsection name
439 name: Variable name
440 Returns:
441 Contents of the setting
442 Raises:
443 KeyError: if the value is not set
444 """
445 raise NotImplementedError(self.get)
447 def get_multivar(self, section: SectionLike, name: NameLike) -> Iterator[Value]:
448 """Retrieve the contents of a multivar configuration setting.
450 Args:
451 section: Tuple with section name and optional subsection namee
452 name: Variable name
453 Returns:
454 Contents of the setting as iterable
455 Raises:
456 KeyError: if the value is not set
457 """
458 raise NotImplementedError(self.get_multivar)
460 @overload
461 def get_boolean(
462 self, section: SectionLike, name: NameLike, default: bool
463 ) -> bool: ...
465 @overload
466 def get_boolean(self, section: SectionLike, name: NameLike) -> bool | None: ...
468 def get_boolean(
469 self, section: SectionLike, name: NameLike, default: bool | None = None
470 ) -> bool | None:
471 """Retrieve a configuration setting as boolean.
473 Args:
474 section: Tuple with section name and optional subsection name
475 name: Name of the setting, including section and possible
476 subsection.
477 default: Default value if setting is not found
479 Returns:
480 Contents of the setting
481 """
482 try:
483 value = self.get(section, name)
484 except KeyError:
485 return default
486 if value.lower() == b"true":
487 return True
488 elif value.lower() == b"false":
489 return False
490 raise ValueError(f"not a valid boolean string: {value!r}")
492 def set(
493 self, section: SectionLike, name: NameLike, value: ValueLike | bool
494 ) -> None:
495 """Set a configuration value.
497 Args:
498 section: Tuple with section name and optional subsection namee
499 name: Name of the configuration value, including section
500 and optional subsection
501 value: value of the setting
502 """
503 raise NotImplementedError(self.set)
505 def items(self, section: SectionLike) -> Iterator[tuple[Name, Value]]:
506 """Iterate over the configuration pairs for a specific section.
508 Args:
509 section: Tuple with section name and optional subsection namee
510 Returns:
511 Iterator over (name, value) pairs
512 """
513 raise NotImplementedError(self.items)
515 def sections(self) -> Iterator[Section]:
516 """Iterate over the sections.
518 Returns: Iterator over section tuples
519 """
520 raise NotImplementedError(self.sections)
522 def has_section(self, name: Section) -> bool:
523 """Check if a specified section exists.
525 Args:
526 name: Name of section to check for
527 Returns:
528 boolean indicating whether the section exists
529 """
530 return name in self.sections()
533class ConfigDict(Config):
534 """Git configuration stored in a dictionary."""
536 def __init__(
537 self,
538 values: MutableMapping[Section, CaseInsensitiveOrderedMultiDict[Name, Value]]
539 | None = None,
540 encoding: str | None = None,
541 ) -> None:
542 """Create a new ConfigDict."""
543 if encoding is None:
544 encoding = sys.getdefaultencoding()
545 self.encoding = encoding
546 self._values: CaseInsensitiveOrderedMultiDict[
547 Section, CaseInsensitiveOrderedMultiDict[Name, Value]
548 ] = CaseInsensitiveOrderedMultiDict.make(
549 values, default_factory=CaseInsensitiveOrderedMultiDict
550 )
552 def __repr__(self) -> str:
553 """Return string representation of ConfigDict."""
554 return f"{self.__class__.__name__}({self._values!r})"
556 def __eq__(self, other: object) -> bool:
557 """Check equality with another ConfigDict."""
558 return isinstance(other, self.__class__) and other._values == self._values
560 def __getitem__(self, key: Section) -> CaseInsensitiveOrderedMultiDict[Name, Value]:
561 """Get configuration values for a section.
563 Raises:
564 KeyError: If section not found
565 """
566 return self._values.__getitem__(key)
568 def __setitem__(
569 self, key: Section, value: CaseInsensitiveOrderedMultiDict[Name, Value]
570 ) -> None:
571 """Set configuration values for a section."""
572 return self._values.__setitem__(key, value)
574 def __delitem__(self, key: Section) -> None:
575 """Delete a configuration section.
577 Raises:
578 KeyError: If section not found
579 """
580 return self._values.__delitem__(key)
582 def __iter__(self) -> Iterator[Section]:
583 """Iterate over configuration sections."""
584 return self._values.__iter__()
586 def __len__(self) -> int:
587 """Return the number of sections."""
588 return self._values.__len__()
590 def keys(self) -> KeysView[Section]:
591 """Return a view of section names."""
592 return self._values.keys()
594 @classmethod
595 def _parse_setting(cls, name: str) -> tuple[str, str | None, str]:
596 parts = name.split(".")
597 if len(parts) == 3:
598 return (parts[0], parts[1], parts[2])
599 else:
600 return (parts[0], None, parts[1])
602 def _check_section_and_name(
603 self, section: SectionLike, name: NameLike
604 ) -> tuple[Section, Name]:
605 if not isinstance(section, tuple):
606 section = (section,)
608 checked_section = tuple(
609 [
610 subsection.encode(self.encoding)
611 if not isinstance(subsection, bytes)
612 else subsection
613 for subsection in section
614 ]
615 )
617 if not isinstance(name, bytes):
618 name = name.encode(self.encoding)
620 return checked_section, name
622 def get_multivar(self, section: SectionLike, name: NameLike) -> Iterator[Value]:
623 """Get multiple values for a configuration setting.
625 Args:
626 section: Section name
627 name: Setting name
629 Returns:
630 Iterator of configuration values
631 """
632 section, name = self._check_section_and_name(section, name)
634 if len(section) > 1:
635 try:
636 return self._values[section].get_all(name)
637 except KeyError:
638 pass
640 return self._values[(section[0],)].get_all(name)
642 def get(
643 self,
644 section: SectionLike,
645 name: NameLike,
646 ) -> Value:
647 """Get a configuration value.
649 Args:
650 section: Section name
651 name: Setting name
653 Returns:
654 Configuration value
656 Raises:
657 KeyError: if the value is not set
658 """
659 section, name = self._check_section_and_name(section, name)
661 if len(section) > 1:
662 try:
663 return self._values[section][name]
664 except KeyError:
665 pass
667 return self._values[(section[0],)][name]
669 def set(
670 self,
671 section: SectionLike,
672 name: NameLike,
673 value: ValueLike | bool,
674 ) -> None:
675 """Set a configuration value.
677 Args:
678 section: Section name
679 name: Setting name
680 value: Configuration value
681 """
682 section, name = self._check_section_and_name(section, name)
684 if isinstance(value, bool):
685 value = b"true" if value else b"false"
687 if not isinstance(value, bytes):
688 value = value.encode(self.encoding)
690 section_dict = self._values.setdefault(section)
691 if hasattr(section_dict, "set"):
692 section_dict.set(name, value)
693 else:
694 section_dict[name] = value
696 def add(
697 self,
698 section: SectionLike,
699 name: NameLike,
700 value: ValueLike | bool,
701 ) -> None:
702 """Add a value to a configuration setting, creating a multivar if needed."""
703 section, name = self._check_section_and_name(section, name)
705 if isinstance(value, bool):
706 value = b"true" if value else b"false"
708 if not isinstance(value, bytes):
709 value = value.encode(self.encoding)
711 self._values.setdefault(section)[name] = value
713 def remove(self, section: SectionLike, name: NameLike) -> None:
714 """Remove a configuration setting.
716 Args:
717 section: Section name
718 name: Setting name
720 Raises:
721 KeyError: If the section or name doesn't exist
722 """
723 section, name = self._check_section_and_name(section, name)
724 del self._values[section][name]
726 def items(self, section: SectionLike) -> Iterator[tuple[Name, Value]]:
727 """Get items in a section."""
728 section_bytes, _ = self._check_section_and_name(section, b"")
729 section_dict = self._values.get(section_bytes)
730 if section_dict is not None:
731 return iter(section_dict.items())
732 return iter([])
734 def sections(self) -> Iterator[Section]:
735 """Get all sections."""
736 return iter(self._values.keys())
739def _format_string(value: bytes) -> bytes:
740 if (
741 value.startswith((b" ", b"\t"))
742 or value.endswith((b" ", b"\t"))
743 or b"#" in value
744 ):
745 return b'"' + _escape_value(value) + b'"'
746 else:
747 return _escape_value(value)
750_ESCAPE_TABLE = {
751 ord(b"\\"): ord(b"\\"),
752 ord(b'"'): ord(b'"'),
753 ord(b"n"): ord(b"\n"),
754 ord(b"t"): ord(b"\t"),
755 ord(b"b"): ord(b"\b"),
756}
757_COMMENT_CHARS = [ord(b"#"), ord(b";")]
758_WHITESPACE_CHARS = [ord(b"\t"), ord(b" ")]
761def _parse_string(value: bytes) -> bytes:
762 value_array = bytearray(value.strip())
763 ret = bytearray()
764 whitespace = bytearray()
765 in_quotes = False
766 i = 0
767 while i < len(value_array):
768 c = value_array[i]
769 if c == ord(b"\\"):
770 i += 1
771 if i >= len(value_array):
772 # Backslash at end of string - treat as literal backslash
773 if whitespace:
774 ret.extend(whitespace)
775 whitespace = bytearray()
776 ret.append(ord(b"\\"))
777 else:
778 try:
779 v = _ESCAPE_TABLE[value_array[i]]
780 if whitespace:
781 ret.extend(whitespace)
782 whitespace = bytearray()
783 ret.append(v)
784 except KeyError:
785 # Unknown escape sequence - treat backslash as literal and process next char normally
786 if whitespace:
787 ret.extend(whitespace)
788 whitespace = bytearray()
789 ret.append(ord(b"\\"))
790 i -= 1 # Reprocess the character after the backslash
791 elif c == ord(b'"'):
792 in_quotes = not in_quotes
793 elif c in _COMMENT_CHARS and not in_quotes:
794 # the rest of the line is a comment
795 break
796 elif c in _WHITESPACE_CHARS:
797 if in_quotes:
798 ret.append(c)
799 else:
800 whitespace.append(c)
801 else:
802 if whitespace:
803 ret.extend(whitespace)
804 whitespace = bytearray()
805 ret.append(c)
806 i += 1
808 if in_quotes:
809 raise ValueError("missing end quote")
811 return bytes(ret)
814def _escape_value(value: bytes) -> bytes:
815 """Escape a value."""
816 value = value.replace(b"\\", b"\\\\")
817 value = value.replace(b"\r", b"\\r")
818 value = value.replace(b"\n", b"\\n")
819 value = value.replace(b"\t", b"\\t")
820 value = value.replace(b'"', b'\\"')
821 return value
824def _escape_subsection(name: bytes) -> bytes:
825 r"""Escape a subsection name for writing in a section header.
827 Per git-config: inside the quoted subsection name, only ``"`` and ``\``
828 need (and may) be escaped; newline and NUL are not permitted at all.
829 """
830 if b"\n" in name or b"\0" in name:
831 raise ValueError(f"subsection name {name!r} contains a forbidden character")
832 return name.replace(b"\\", b"\\\\").replace(b'"', b'\\"')
835def _unescape_subsection(name: bytes) -> bytes:
836 r"""Unescape a quoted subsection name read from a section header.
838 Per git-config, ``\"`` and ``\\`` are the only recognised escapes.
839 Git silently drops the backslash on any other ``\x`` sequence, so we
840 match that lenient behaviour to stay compatible with config files
841 written by git or by hand (notably ``includeIf`` headers containing
842 Windows paths where backslashes were not doubled).
843 """
844 out = bytearray()
845 i = 0
846 while i < len(name):
847 c = name[i : i + 1]
848 if c == b"\\" and i + 1 < len(name):
849 out += name[i + 1 : i + 2]
850 i += 2
851 else:
852 out += c
853 i += 1
854 return bytes(out)
857def _check_variable_name(name: bytes) -> bool:
858 for i in range(len(name)):
859 c = name[i : i + 1]
860 if not c.isalnum() and c != b"-":
861 return False
862 return True
865def _check_section_name(name: bytes) -> bool:
866 for i in range(len(name)):
867 c = name[i : i + 1]
868 if not c.isalnum() and c not in (b"-", b"."):
869 return False
870 return True
873def _strip_comments(line: bytes) -> bytes:
874 comment_bytes = {ord(b"#"), ord(b";")}
875 quote = ord(b'"')
876 string_open = False
877 # Normalize line to bytearray for simple 2/3 compatibility
878 for i, character in enumerate(bytearray(line)):
879 # Comment characters outside balanced quotes denote comment start
880 if character == quote:
881 string_open = not string_open
882 elif not string_open and character in comment_bytes:
883 return line[:i]
884 return line
887def _is_line_continuation(value: bytes) -> bool:
888 """Check if a value ends with a line continuation backslash.
890 A line continuation occurs when a line ends with a backslash that is:
891 1. Not escaped (not preceded by another backslash)
892 2. Not within quotes
894 Args:
895 value: The value to check
897 Returns:
898 True if the value ends with a line continuation backslash
899 """
900 if not value.endswith((b"\\\n", b"\\\r\n")):
901 return False
903 # Remove only the newline characters, keep the content including the backslash
904 if value.endswith(b"\\\r\n"):
905 content = value[:-2] # Remove \r\n, keep the \
906 else:
907 content = value[:-1] # Remove \n, keep the \
909 if not content.endswith(b"\\"):
910 return False
912 # Count consecutive backslashes at the end
913 backslash_count = 0
914 for i in range(len(content) - 1, -1, -1):
915 if content[i : i + 1] == b"\\":
916 backslash_count += 1
917 else:
918 break
920 # If we have an odd number of backslashes, the last one is a line continuation
921 # If we have an even number, they are all escaped and there's no continuation
922 return backslash_count % 2 == 1
925def _parse_section_header_line(line: bytes) -> tuple[Section, bytes]:
926 # Parse section header ("[bla]")
927 line = _strip_comments(line).rstrip()
928 in_quotes = False
929 escaped = False
930 for i, c in enumerate(line):
931 if escaped:
932 escaped = False
933 continue
934 if c == ord(b'"'):
935 in_quotes = not in_quotes
936 if c == ord(b"\\"):
937 escaped = True
938 if c == ord(b"]") and not in_quotes:
939 last = i
940 break
941 else:
942 raise ValueError("expected trailing ]")
943 pts = line[1:last].split(b" ", 1)
944 line = line[last + 1 :]
945 section: Section
946 if len(pts) == 2:
947 # Handle subsections - Git allows more complex syntax for certain sections like includeIf
948 if pts[1][:1] == b'"' and pts[1][-1:] == b'"':
949 # Standard quoted subsection
950 pts[1] = _unescape_subsection(pts[1][1:-1])
951 elif pts[0] == b"includeIf":
952 # Special handling for includeIf sections which can have complex conditions
953 # Git allows these without strict quote validation
954 pts[1] = pts[1].strip()
955 if pts[1][:1] == b'"' and pts[1][-1:] == b'"':
956 pts[1] = _unescape_subsection(pts[1][1:-1])
957 else:
958 # Other sections must have quoted subsections
959 raise ValueError(f"Invalid subsection {pts[1]!r}")
960 if not _check_section_name(pts[0]):
961 raise ValueError(f"invalid section name {pts[0]!r}")
962 section = (pts[0], pts[1])
963 else:
964 if not _check_section_name(pts[0]):
965 raise ValueError(f"invalid section name {pts[0]!r}")
966 pts = pts[0].split(b".", 1)
967 if len(pts) == 2:
968 section = (pts[0], pts[1])
969 else:
970 section = (pts[0],)
971 return section, line
974class ConfigFile(ConfigDict):
975 """A Git configuration file, like .git/config or ~/.gitconfig."""
977 def __init__(
978 self,
979 values: MutableMapping[Section, CaseInsensitiveOrderedMultiDict[Name, Value]]
980 | None = None,
981 encoding: str | None = None,
982 ) -> None:
983 """Initialize a ConfigFile.
985 Args:
986 values: Optional mapping of configuration values
987 encoding: Optional encoding for the file (defaults to system encoding)
988 """
989 super().__init__(values=values, encoding=encoding)
990 self.path: str | None = None
991 self._included_paths: set[str] = set() # Track included files to prevent cycles
993 @classmethod
994 def from_file(
995 cls,
996 f: IO[bytes],
997 *,
998 config_dir: str | None = None,
999 included_paths: set[str] | None = None,
1000 include_depth: int = 0,
1001 max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH,
1002 file_opener: FileOpener | None = None,
1003 condition_matchers: Mapping[str, ConditionMatcher] | None = None,
1004 ) -> "ConfigFile":
1005 """Read configuration from a file-like object.
1007 Args:
1008 f: File-like object to read from
1009 config_dir: Directory containing the config file (for relative includes)
1010 included_paths: Set of already included paths (to prevent cycles)
1011 include_depth: Current include depth (to prevent infinite recursion)
1012 max_include_depth: Maximum allowed include depth
1013 file_opener: Optional callback to open included files
1014 condition_matchers: Optional dict of condition matchers for includeIf
1015 """
1016 if include_depth > max_include_depth:
1017 # Prevent excessive recursion
1018 raise ValueError(f"Maximum include depth ({max_include_depth}) exceeded")
1020 ret = cls()
1021 if included_paths is not None:
1022 ret._included_paths = included_paths.copy()
1024 section: Section | None = None
1025 setting = None
1026 continuation = None
1027 for lineno, line in enumerate(f.readlines()):
1028 if lineno == 0 and line.startswith(b"\xef\xbb\xbf"):
1029 line = line[3:]
1030 line = line.lstrip()
1031 if setting is None:
1032 if len(line) > 0 and line[:1] == b"[":
1033 section, line = _parse_section_header_line(line)
1034 ret._values.setdefault(section)
1035 if _strip_comments(line).strip() == b"":
1036 continue
1037 if section is None:
1038 raise ValueError(f"setting {line!r} without section")
1039 try:
1040 setting, value = line.split(b"=", 1)
1041 except ValueError:
1042 setting = line
1043 value = b"true"
1044 setting = setting.strip()
1045 if not _check_variable_name(setting):
1046 raise ValueError(f"invalid variable name {setting!r}")
1047 if _is_line_continuation(value):
1048 if value.endswith(b"\\\r\n"):
1049 continuation = value[:-3]
1050 else:
1051 continuation = value[:-2]
1052 else:
1053 continuation = None
1054 value = _parse_string(value)
1055 ret._values[section][setting] = value
1057 # Process include/includeIf directives
1058 ret._handle_include_directive(
1059 section,
1060 setting,
1061 value,
1062 config_dir=config_dir,
1063 include_depth=include_depth,
1064 max_include_depth=max_include_depth,
1065 file_opener=file_opener,
1066 condition_matchers=condition_matchers,
1067 )
1069 setting = None
1070 else: # continuation line
1071 assert continuation is not None
1072 if _is_line_continuation(line):
1073 if line.endswith(b"\\\r\n"):
1074 continuation += line[:-3]
1075 else:
1076 continuation += line[:-2]
1077 else:
1078 continuation += line
1079 value = _parse_string(continuation)
1080 assert section is not None # Already checked above
1081 ret._values[section][setting] = value
1083 # Process include/includeIf directives
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 continuation = None
1096 setting = None
1097 return ret
1099 def _handle_include_directive(
1100 self,
1101 section: Section | None,
1102 setting: bytes,
1103 value: bytes,
1104 *,
1105 config_dir: str | None,
1106 include_depth: int,
1107 max_include_depth: int,
1108 file_opener: FileOpener | None,
1109 condition_matchers: Mapping[str, ConditionMatcher] | None,
1110 ) -> None:
1111 """Handle include/includeIf directives during config parsing."""
1112 if (
1113 section is not None
1114 and setting == b"path"
1115 and (
1116 section[0].lower() == b"include"
1117 or (len(section) > 1 and section[0].lower() == b"includeif")
1118 )
1119 ):
1120 self._process_include(
1121 section,
1122 value,
1123 config_dir=config_dir,
1124 include_depth=include_depth,
1125 max_include_depth=max_include_depth,
1126 file_opener=file_opener,
1127 condition_matchers=condition_matchers,
1128 )
1130 def _process_include(
1131 self,
1132 section: Section,
1133 path_value: bytes,
1134 *,
1135 config_dir: str | None,
1136 include_depth: int,
1137 max_include_depth: int,
1138 file_opener: FileOpener | None,
1139 condition_matchers: Mapping[str, ConditionMatcher] | None,
1140 ) -> None:
1141 """Process an include or includeIf directive."""
1142 path_str = path_value.decode(self.encoding, errors="replace")
1144 # Handle includeIf conditions
1145 if len(section) > 1 and section[0].lower() == b"includeif":
1146 condition = section[1].decode(self.encoding, errors="replace")
1147 if not self._evaluate_includeif_condition(
1148 condition, config_dir, condition_matchers
1149 ):
1150 return
1152 # Resolve the include path
1153 include_path = self._resolve_include_path(path_str, config_dir)
1154 if not include_path:
1155 return
1157 # Check for circular includes
1158 try:
1159 abs_path = str(Path(include_path).resolve())
1160 except (OSError, ValueError) as e:
1161 # Invalid path - log and skip
1162 logger.debug("Invalid include path %r: %s", include_path, e)
1163 return
1164 if abs_path in self._included_paths:
1165 return
1167 # Load and merge the included file
1168 try:
1169 # Use provided file opener or default to GitFile
1170 opener: FileOpener
1171 if file_opener is None:
1173 def opener(path: str | os.PathLike[str]) -> IO[bytes]:
1174 return GitFile(path, "rb")
1175 else:
1176 opener = file_opener
1178 f = opener(include_path)
1179 except (OSError, ValueError) as e:
1180 # Git silently ignores missing or unreadable include files
1181 # Log for debugging purposes
1182 logger.debug("Invalid include path %r: %s", include_path, e)
1183 else:
1184 with f as included_file:
1185 # Track this path to prevent cycles
1186 self._included_paths.add(abs_path)
1188 # Parse the included file
1189 included_config = ConfigFile.from_file(
1190 included_file,
1191 config_dir=os.path.dirname(include_path),
1192 included_paths=self._included_paths,
1193 include_depth=include_depth + 1,
1194 max_include_depth=max_include_depth,
1195 file_opener=file_opener,
1196 condition_matchers=condition_matchers,
1197 )
1199 # Merge the included configuration
1200 self._merge_config(included_config)
1202 def _merge_config(self, other: "ConfigFile") -> None:
1203 """Merge another config file into this one."""
1204 for section, values in other._values.items():
1205 if section not in self._values:
1206 self._values[section] = CaseInsensitiveOrderedMultiDict()
1207 for key, value in values.items():
1208 self._values[section][key] = value
1210 def _resolve_include_path(self, path: str, config_dir: str | None) -> str | None:
1211 """Resolve an include path to an absolute path."""
1212 # Expand ~ to home directory
1213 path = os.path.expanduser(path)
1215 # If path is relative and we have a config directory, make it relative to that
1216 if not os.path.isabs(path) and config_dir:
1217 path = os.path.join(config_dir, path)
1219 return path
1221 def _evaluate_includeif_condition(
1222 self,
1223 condition: str,
1224 config_dir: str | None = None,
1225 condition_matchers: Mapping[str, ConditionMatcher] | None = None,
1226 ) -> bool:
1227 """Evaluate an includeIf condition."""
1228 # Try custom matchers first if provided
1229 if condition_matchers:
1230 for prefix, matcher in condition_matchers.items():
1231 if condition.startswith(prefix):
1232 return matcher(condition[len(prefix) :])
1234 # Fall back to built-in matchers
1235 if condition.startswith("hasconfig:"):
1236 return self._evaluate_hasconfig_condition(condition[10:])
1237 else:
1238 # Unknown condition type - log and ignore (Git behavior)
1239 logger.debug("Unknown includeIf condition: %r", condition)
1240 return False
1242 def _evaluate_hasconfig_condition(self, condition: str) -> bool:
1243 """Evaluate a hasconfig condition.
1245 Format: hasconfig:config.key:pattern
1246 Example: hasconfig:remote.*.url:ssh://org-*@github.com/**
1247 """
1248 # Split on the first colon to separate config key from pattern
1249 parts = condition.split(":", 1)
1250 if len(parts) != 2:
1251 logger.debug("Invalid hasconfig condition format: %r", condition)
1252 return False
1254 config_key, pattern = parts
1256 # Parse the config key to get section and name
1257 key_parts = config_key.split(".", 2)
1258 if len(key_parts) < 2:
1259 logger.debug("Invalid hasconfig config key: %r", config_key)
1260 return False
1262 # Handle wildcards in section names (e.g., remote.*)
1263 if len(key_parts) == 3 and key_parts[1] == "*":
1264 # Match any subsection
1265 section_prefix = key_parts[0].encode(self.encoding)
1266 name = key_parts[2].encode(self.encoding)
1268 # Check all sections that match the pattern
1269 for section in self.sections():
1270 if len(section) == 2 and section[0] == section_prefix:
1271 try:
1272 values = list(self.get_multivar(section, name))
1273 for value in values:
1274 if self._match_hasconfig_pattern(value, pattern):
1275 return True
1276 except KeyError:
1277 continue
1278 else:
1279 # Direct section lookup
1280 if len(key_parts) == 2:
1281 section = (key_parts[0].encode(self.encoding),)
1282 name = key_parts[1].encode(self.encoding)
1283 else:
1284 section = (
1285 key_parts[0].encode(self.encoding),
1286 key_parts[1].encode(self.encoding),
1287 )
1288 name = key_parts[2].encode(self.encoding)
1290 try:
1291 values = list(self.get_multivar(section, name))
1292 for value in values:
1293 if self._match_hasconfig_pattern(value, pattern):
1294 return True
1295 except KeyError:
1296 pass
1298 return False
1300 def _match_hasconfig_pattern(self, value: bytes, pattern: str) -> bool:
1301 """Match a config value against a hasconfig pattern.
1303 Supports simple glob patterns like ``*`` and ``**``.
1304 """
1305 value_str = value.decode(self.encoding, errors="replace")
1306 return match_glob_pattern(value_str, pattern)
1308 @classmethod
1309 def from_path(
1310 cls,
1311 path: str | os.PathLike[str],
1312 *,
1313 max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH,
1314 file_opener: FileOpener | None = None,
1315 condition_matchers: Mapping[str, ConditionMatcher] | None = None,
1316 ) -> "ConfigFile":
1317 """Read configuration from a file on disk.
1319 Args:
1320 path: Path to the configuration file
1321 max_include_depth: Maximum allowed include depth
1322 file_opener: Optional callback to open included files
1323 condition_matchers: Optional dict of condition matchers for includeIf
1324 """
1325 abs_path = os.fspath(path)
1326 config_dir = os.path.dirname(abs_path)
1328 # Use provided file opener or default to GitFile
1329 opener: FileOpener
1330 if file_opener is None:
1332 def opener(p: str | os.PathLike[str]) -> IO[bytes]:
1333 return GitFile(p, "rb")
1334 else:
1335 opener = file_opener
1337 with opener(abs_path) as f:
1338 ret = cls.from_file(
1339 f,
1340 config_dir=config_dir,
1341 max_include_depth=max_include_depth,
1342 file_opener=file_opener,
1343 condition_matchers=condition_matchers,
1344 )
1345 ret.path = abs_path
1346 return ret
1348 def write_to_path(self, path: str | os.PathLike[str] | None = None) -> None:
1349 """Write configuration to a file on disk."""
1350 if path is None:
1351 if self.path is None:
1352 raise ValueError("No path specified and no default path available")
1353 path_to_use: str | os.PathLike[str] = self.path
1354 else:
1355 path_to_use = path
1356 with GitFile(path_to_use, "wb") as f:
1357 self.write_to_file(f)
1359 def write_to_file(self, f: IO[bytes] | _GitFile) -> None:
1360 """Write configuration to a file-like object."""
1361 for section, values in self._values.items():
1362 try:
1363 section_name, subsection_name = section
1364 except ValueError:
1365 (section_name,) = section
1366 subsection_name = None
1367 if subsection_name is None:
1368 f.write(b"[" + section_name + b"]\n")
1369 else:
1370 f.write(
1371 b"["
1372 + section_name
1373 + b' "'
1374 + _escape_subsection(subsection_name)
1375 + b'"]\n'
1376 )
1377 for key, value in values.items():
1378 value = _format_string(value)
1379 f.write(b"\t" + key + b" = " + value + b"\n")
1382def get_xdg_config_home_path(*path_segments: str) -> str:
1383 """Get a path in the XDG config home directory.
1385 Args:
1386 *path_segments: Path segments to join to the XDG config home
1388 Returns:
1389 Full path in XDG config home directory
1390 """
1391 xdg_config_home = os.environ.get(
1392 "XDG_CONFIG_HOME",
1393 os.path.expanduser("~/.config/"),
1394 )
1395 return os.path.join(xdg_config_home, *path_segments)
1398def _find_git_in_win_path() -> Iterator[str]:
1399 for exe in ("git.exe", "git.cmd"):
1400 for path in os.environ.get("PATH", "").split(";"):
1401 if os.path.exists(os.path.join(path, exe)):
1402 # in windows native shells (powershell/cmd) exe path is
1403 # .../Git/bin/git.exe or .../Git/cmd/git.exe
1404 #
1405 # in git-bash exe path is .../Git/mingw64/bin/git.exe
1406 git_dir, _bin_dir = os.path.split(path)
1407 yield git_dir
1408 parent_dir, basename = os.path.split(git_dir)
1409 if basename == "mingw32" or basename == "mingw64":
1410 yield parent_dir
1411 break
1414def _find_git_in_win_reg() -> Iterator[str]:
1415 import platform
1416 import winreg
1418 if platform.machine() == "AMD64":
1419 subkey = (
1420 "SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\"
1421 "CurrentVersion\\Uninstall\\Git_is1"
1422 )
1423 else:
1424 subkey = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Git_is1"
1426 for key in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE): # type: ignore[attr-defined,unused-ignore]
1427 with suppress(OSError):
1428 with winreg.OpenKey(key, subkey) as k: # type: ignore[attr-defined,unused-ignore]
1429 val, typ = winreg.QueryValueEx(k, "InstallLocation") # type: ignore[attr-defined,unused-ignore]
1430 if typ == winreg.REG_SZ: # type: ignore[attr-defined,unused-ignore]
1431 yield val
1434# There is no set standard for system config dirs on windows. We try the
1435# following:
1436# - %PROGRAMFILES%/Git/etc/gitconfig - Git for Windows (msysgit) config dir
1437# Used if CGit installation (Git/bin/git.exe) is found in PATH in the
1438# system registry
1439def get_win_system_paths() -> Iterator[str]:
1440 """Get current Windows system Git config paths.
1442 Only returns the current Git for Windows config location, not legacy paths.
1443 """
1444 # Try to find Git installation from PATH first
1445 for git_dir in _find_git_in_win_path():
1446 yield os.path.join(git_dir, "etc", "gitconfig")
1447 return # Only use the first found path
1449 # Fall back to registry if not found in PATH
1450 for git_dir in _find_git_in_win_reg():
1451 yield os.path.join(git_dir, "etc", "gitconfig")
1452 return # Only use the first found path
1455def get_win_legacy_system_paths() -> Iterator[str]:
1456 """Get legacy Windows system Git config paths.
1458 Returns all possible config paths including deprecated locations.
1459 This function can be used for diagnostics or migration purposes.
1460 """
1461 # Include deprecated PROGRAMDATA location
1462 if "PROGRAMDATA" in os.environ:
1463 yield os.path.join(os.environ["PROGRAMDATA"], "Git", "config")
1465 # Include all Git installations found
1466 for git_dir in _find_git_in_win_path():
1467 yield os.path.join(git_dir, "etc", "gitconfig")
1468 for git_dir in _find_git_in_win_reg():
1469 yield os.path.join(git_dir, "etc", "gitconfig")
1472def env_config(
1473 environ: Mapping[str, str],
1474) -> "ConfigFile | None":
1475 """Build a ConfigFile from GIT_CONFIG_COUNT/KEY_n/VALUE_n vars.
1477 See git-config(1). Any missing key/value, a key without a dot, or a
1478 non-numeric or negative ``GIT_CONFIG_COUNT`` is treated as an error.
1479 Callers that want git's "env overrides everything" precedence should
1480 prepend the result to their ``StackedConfig.backends``; nothing in
1481 dulwich consults ``os.environ`` for these variables on its own.
1483 Args:
1484 environ: Mapping to read the variables from (e.g. ``os.environ``).
1486 Returns:
1487 A ConfigFile holding the overrides, or None if GIT_CONFIG_COUNT is
1488 unset or empty (which git treats as zero pairs).
1489 """
1490 raw_count = environ.get("GIT_CONFIG_COUNT")
1491 if raw_count is None or raw_count == "":
1492 return None
1493 try:
1494 count = int(raw_count)
1495 except ValueError:
1496 raise ValueError(f"bogus count in GIT_CONFIG_COUNT: {raw_count!r}") from None
1497 if count < 0:
1498 raise ValueError(f"bogus count in GIT_CONFIG_COUNT: {raw_count!r}")
1500 cf = ConfigFile()
1501 for i in range(count):
1502 key_var = f"GIT_CONFIG_KEY_{i}"
1503 value_var = f"GIT_CONFIG_VALUE_{i}"
1504 try:
1505 key = environ[key_var]
1506 except KeyError:
1507 raise KeyError(f"missing config key {key_var}") from None
1508 try:
1509 value = environ[value_var]
1510 except KeyError:
1511 raise KeyError(f"missing config value {value_var}") from None
1512 if "." not in key:
1513 raise ValueError(f"invalid config format: {key}")
1514 # Git keys are <section>.<name> or <section>.<subsection>.<name>.
1515 # The subsection (if present) may itself contain dots.
1516 first_dot = key.find(".")
1517 last_dot = key.rfind(".")
1518 section_name = key[:first_dot]
1519 name = key[last_dot + 1 :]
1520 if first_dot == last_dot:
1521 section: Section = (section_name.encode("utf-8"),)
1522 else:
1523 subsection = key[first_dot + 1 : last_dot]
1524 section = (
1525 section_name.encode("utf-8"),
1526 subsection.encode("utf-8"),
1527 )
1528 cf.add(section, name.encode("utf-8"), value.encode("utf-8"))
1529 return cf
1532class StackedConfig(Config):
1533 """Configuration which reads from multiple config files.."""
1535 def __init__(
1536 self, backends: list[ConfigFile], writable: ConfigFile | None = None
1537 ) -> None:
1538 """Initialize a StackedConfig.
1540 Args:
1541 backends: List of config files to read from (in order of precedence)
1542 writable: Optional config file to write changes to
1543 """
1544 self.backends = backends
1545 self.writable = writable
1547 def __repr__(self) -> str:
1548 """Return string representation of StackedConfig."""
1549 return f"<{self.__class__.__name__} for {self.backends!r}>"
1551 @classmethod
1552 def default(cls) -> "StackedConfig":
1553 """Create a StackedConfig with default system/user config files.
1555 Returns:
1556 StackedConfig with default configuration files loaded
1557 """
1558 return cls(cls.default_backends())
1560 @classmethod
1561 def default_backends(cls) -> list[ConfigFile]:
1562 """Retrieve the default configuration.
1564 See git-config(1) for details on the files searched.
1565 """
1566 paths = []
1568 # Handle GIT_CONFIG_GLOBAL - overrides user config paths
1569 try:
1570 paths.append(os.environ["GIT_CONFIG_GLOBAL"])
1571 except KeyError:
1572 paths.append(os.path.expanduser("~/.gitconfig"))
1573 paths.append(get_xdg_config_home_path("git", "config"))
1575 # Handle GIT_CONFIG_SYSTEM and GIT_CONFIG_NOSYSTEM
1576 try:
1577 paths.append(os.environ["GIT_CONFIG_SYSTEM"])
1578 except KeyError:
1579 if "GIT_CONFIG_NOSYSTEM" not in os.environ:
1580 paths.append("/etc/gitconfig")
1581 if sys.platform == "win32":
1582 paths.extend(get_win_system_paths())
1584 logger.debug("Loading gitconfig from paths: %s", paths)
1586 backends = []
1587 for path in paths:
1588 try:
1589 cf = ConfigFile.from_path(path)
1590 logger.debug("Successfully loaded gitconfig from: %s", path)
1591 except FileNotFoundError:
1592 logger.debug("Gitconfig file not found: %s", path)
1593 continue
1594 backends.append(cf)
1596 return backends
1598 def get(self, section: SectionLike, name: NameLike) -> Value:
1599 """Get value from configuration."""
1600 if not isinstance(section, tuple):
1601 section = (section,)
1602 for backend in self.backends:
1603 try:
1604 return backend.get(section, name)
1605 except KeyError:
1606 pass
1607 raise KeyError(name)
1609 def get_multivar(self, section: SectionLike, name: NameLike) -> Iterator[Value]:
1610 """Get multiple values from configuration."""
1611 if not isinstance(section, tuple):
1612 section = (section,)
1613 for backend in self.backends:
1614 try:
1615 yield from backend.get_multivar(section, name)
1616 except KeyError:
1617 pass
1619 def set(
1620 self, section: SectionLike, name: NameLike, value: ValueLike | bool
1621 ) -> None:
1622 """Set value in configuration."""
1623 if self.writable is None:
1624 raise NotImplementedError(self.set)
1625 return self.writable.set(section, name, value)
1627 def sections(self) -> Iterator[Section]:
1628 """Get all sections."""
1629 seen = set()
1630 for backend in self.backends:
1631 for section in backend.sections():
1632 if section not in seen:
1633 seen.add(section)
1634 yield section
1637def read_submodules(
1638 path: str | os.PathLike[str],
1639) -> Iterator[tuple[bytes, bytes, bytes]]:
1640 """Read a .gitmodules file."""
1641 cfg = ConfigFile.from_path(path)
1642 return parse_submodules(cfg)
1645def parse_submodules(config: ConfigFile) -> Iterator[tuple[bytes, bytes, bytes]]:
1646 """Parse a gitmodules GitConfig file, returning submodules.
1648 Args:
1649 config: A `ConfigFile`
1650 Returns:
1651 list of tuples (submodule path, url, name),
1652 where name is quoted part of the section's name.
1653 """
1654 for section in config.sections():
1655 section_kind, section_name = section
1656 if section_kind == b"submodule":
1657 try:
1658 sm_path = config.get(section, b"path")
1659 sm_url = config.get(section, b"url")
1660 yield (sm_path, sm_url, section_name)
1661 except KeyError:
1662 # If either path or url is missing, just ignore this
1663 # submodule entry and move on to the next one. This is
1664 # how git itself handles malformed .gitmodule entries.
1665 pass
1668def iter_instead_of(config: Config, push: bool = False) -> Iterable[tuple[str, str]]:
1669 """Iterate over insteadOf / pushInsteadOf values."""
1670 for section in config.sections():
1671 if section[0] != b"url":
1672 continue
1673 replacement = section[1]
1674 try:
1675 needles = list(config.get_multivar(section, "insteadOf"))
1676 except KeyError:
1677 needles = []
1678 if push:
1679 try:
1680 needles += list(config.get_multivar(section, "pushInsteadOf"))
1681 except KeyError:
1682 pass
1683 for needle in needles:
1684 assert isinstance(needle, bytes)
1685 yield needle.decode("utf-8"), replacement.decode("utf-8")
1688def get_git_proxy_command(config: Config, host: str) -> str | None:
1689 """Look up the core.gitProxy command for the given host.
1691 The ``core.gitProxy`` variable can appear multiple times, each with an
1692 optional ``for <domain>`` suffix. The first entry whose domain suffix
1693 matches the end of *host* wins; an entry without a ``for`` clause is a
1694 catch-all default.
1696 Args:
1697 config: A Config instance.
1698 host: The hostname being connected to.
1700 Returns:
1701 The proxy command string, or ``None`` if no proxy is configured.
1702 """
1703 try:
1704 values = list(config.get_multivar((b"core",), b"gitProxy"))
1705 except KeyError:
1706 return None
1708 default_proxy: str | None = None
1709 for raw in values:
1710 text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
1711 # Entries may look like:
1712 # proxy-command for kernel.org
1713 # default-proxy
1714 parts = text.rsplit(" for ", 1)
1715 if len(parts) == 2:
1716 command, domain = parts[0].strip(), parts[1].strip()
1717 if host == domain or host.endswith("." + domain):
1718 return command
1719 else:
1720 default_proxy = text.strip()
1722 return default_proxy
1725def apply_instead_of(config: Config, orig_url: str, push: bool = False) -> str:
1726 """Apply insteadOf / pushInsteadOf to a URL."""
1727 longest_needle = ""
1728 updated_url = orig_url
1729 for needle, replacement in iter_instead_of(config, push):
1730 if not orig_url.startswith(needle):
1731 continue
1732 if len(longest_needle) < len(needle):
1733 longest_needle = needle
1734 updated_url = replacement + orig_url[len(needle) :]
1735 return updated_url