Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/dulwich/config.py: 63%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# config.py - Reading and writing Git config files
2# Copyright (C) 2011-2013 Jelmer Vernooij <jelmer@jelmer.uk>
3#
4# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
5# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
6# General Public License as published by the Free Software Foundation; version 2.0
7# or (at your option) any later version. You can redistribute it and/or
8# modify it under the terms of either of these two licenses.
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15#
16# You should have received a copy of the licenses; if not, see
17# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
18# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
19# License, Version 2.0.
20#
22"""Reading and writing Git configuration files.
24Todo:
25 * preserve formatting when updating configuration files
26"""
28import logging
29import os
30import re
31import sys
32from collections.abc import (
33 ItemsView,
34 Iterable,
35 Iterator,
36 KeysView,
37 MutableMapping,
38 ValuesView,
39)
40from contextlib import suppress
41from pathlib import Path
42from typing import (
43 IO,
44 Any,
45 BinaryIO,
46 Callable,
47 Generic,
48 Optional,
49 TypeVar,
50 Union,
51 cast,
52 overload,
53)
55from .file import GitFile, _GitFile
57ConfigKey = Union[str, bytes, tuple[Union[str, bytes], ...]]
58ConfigValue = Union[str, bytes, bool, int]
60logger = logging.getLogger(__name__)
62# Type for file opener callback
63FileOpener = Callable[[Union[str, os.PathLike]], BinaryIO]
65# Type for includeIf condition matcher
66# Takes the condition value (e.g., "main" for onbranch:main) and returns bool
67ConditionMatcher = Callable[[str], bool]
69# Security limits for include files
70MAX_INCLUDE_FILE_SIZE = 1024 * 1024 # 1MB max for included config files
71DEFAULT_MAX_INCLUDE_DEPTH = 10 # Maximum recursion depth for includes
74def _match_gitdir_pattern(
75 path: bytes, pattern: bytes, ignorecase: bool = False
76) -> bool:
77 """Simple gitdir pattern matching for includeIf conditions.
79 This handles the basic gitdir patterns used in includeIf directives.
80 """
81 # Convert to strings for easier manipulation
82 path_str = path.decode("utf-8", errors="replace")
83 pattern_str = pattern.decode("utf-8", errors="replace")
85 # Normalize paths to use forward slashes for consistent matching
86 path_str = path_str.replace("\\", "/")
87 pattern_str = pattern_str.replace("\\", "/")
89 if ignorecase:
90 path_str = path_str.lower()
91 pattern_str = pattern_str.lower()
93 # Handle the common cases for gitdir patterns
94 if pattern_str.startswith("**/") and pattern_str.endswith("/**"):
95 # Pattern like **/dirname/** should match any path containing dirname
96 dirname = pattern_str[3:-3] # Remove **/ and /**
97 # Check if path contains the directory name as a path component
98 return ("/" + dirname + "/") in path_str or path_str.endswith("/" + dirname)
99 elif pattern_str.startswith("**/"):
100 # Pattern like **/filename
101 suffix = pattern_str[3:] # Remove **/
102 return suffix in path_str or path_str.endswith("/" + suffix)
103 elif pattern_str.endswith("/**"):
104 # Pattern like /path/to/dir/** should match /path/to/dir and any subdirectory
105 base_pattern = pattern_str[:-3] # Remove /**
106 return path_str == base_pattern or path_str.startswith(base_pattern + "/")
107 elif "**" in pattern_str:
108 # Handle patterns with ** in the middle
109 parts = pattern_str.split("**")
110 if len(parts) == 2:
111 prefix, suffix = parts
112 # Path must start with prefix and end with suffix (if any)
113 if prefix and not path_str.startswith(prefix):
114 return False
115 if suffix and not path_str.endswith(suffix):
116 return False
117 return True
119 # Direct match or simple glob pattern
120 if "*" in pattern_str or "?" in pattern_str or "[" in pattern_str:
121 import fnmatch
123 return fnmatch.fnmatch(path_str, pattern_str)
124 else:
125 return path_str == pattern_str
128def match_glob_pattern(value: str, pattern: str) -> bool:
129 r"""Match a value against a glob pattern.
131 Supports simple glob patterns like ``*`` and ``**``.
133 Raises:
134 ValueError: If the pattern is invalid
135 """
136 # Convert glob pattern to regex
137 pattern_escaped = re.escape(pattern)
138 # Replace escaped \*\* with .* (match anything)
139 pattern_escaped = pattern_escaped.replace(r"\*\*", ".*")
140 # Replace escaped \* with [^/]* (match anything except /)
141 pattern_escaped = pattern_escaped.replace(r"\*", "[^/]*")
142 # Anchor the pattern
143 pattern_regex = f"^{pattern_escaped}$"
145 try:
146 return bool(re.match(pattern_regex, value))
147 except re.error as e:
148 raise ValueError(f"Invalid glob pattern {pattern!r}: {e}")
151def lower_key(key: ConfigKey) -> ConfigKey:
152 if isinstance(key, (bytes, str)):
153 return key.lower()
155 if isinstance(key, tuple):
156 # For config sections, only lowercase the section name (first element)
157 # but preserve the case of subsection names (remaining elements)
158 if len(key) > 0:
159 first = key[0]
160 assert isinstance(first, (bytes, str))
161 return (first.lower(), *key[1:])
162 return key
164 raise TypeError(key)
167K = TypeVar("K", bound=ConfigKey) # Key type must be ConfigKey
168V = TypeVar("V") # Value type
169_T = TypeVar("_T") # For get() default parameter
172class CaseInsensitiveOrderedMultiDict(MutableMapping[K, V], Generic[K, V]):
173 def __init__(self, default_factory: Optional[Callable[[], V]] = None) -> None:
174 self._real: list[tuple[K, V]] = []
175 self._keyed: dict[Any, V] = {}
176 self._default_factory = default_factory
178 @classmethod
179 def make(
180 cls,
181 dict_in: Optional[
182 Union[MutableMapping[K, V], "CaseInsensitiveOrderedMultiDict[K, V]"]
183 ] = None,
184 default_factory: Optional[Callable[[], V]] = None,
185 ) -> "CaseInsensitiveOrderedMultiDict[K, V]":
186 if isinstance(dict_in, cls):
187 return dict_in
189 out = cls(default_factory=default_factory)
191 if dict_in is None:
192 return out
194 if not isinstance(dict_in, MutableMapping):
195 raise TypeError
197 for key, value in dict_in.items():
198 out[key] = value
200 return out
202 def __len__(self) -> int:
203 return len(self._keyed)
205 def keys(self) -> KeysView[K]:
206 return self._keyed.keys() # type: ignore[return-value]
208 def items(self) -> ItemsView[K, V]:
209 # Return a view that iterates over the real list to preserve order
210 class OrderedItemsView(ItemsView[K, V]):
211 def __init__(self, mapping: CaseInsensitiveOrderedMultiDict[K, V]):
212 self._mapping = mapping
214 def __iter__(self) -> Iterator[tuple[K, V]]:
215 return iter(self._mapping._real)
217 def __len__(self) -> int:
218 return len(self._mapping._real)
220 def __contains__(self, item: object) -> bool:
221 if not isinstance(item, tuple) or len(item) != 2:
222 return False
223 key, value = item
224 return any(k == key and v == value for k, v in self._mapping._real)
226 return OrderedItemsView(self)
228 def __iter__(self) -> Iterator[K]:
229 return iter(self._keyed)
231 def values(self) -> ValuesView[V]:
232 return self._keyed.values()
234 def __setitem__(self, key: K, value: V) -> None:
235 self._real.append((key, value))
236 self._keyed[lower_key(key)] = value
238 def set(self, key: K, value: V) -> None:
239 # This method replaces all existing values for the key
240 lower = lower_key(key)
241 self._real = [(k, v) for k, v in self._real if lower_key(k) != lower]
242 self._real.append((key, value))
243 self._keyed[lower] = value
245 def __delitem__(self, key: K) -> None:
246 lower_k = lower_key(key)
247 del self._keyed[lower_k]
248 for i, (actual, unused_value) in reversed(list(enumerate(self._real))):
249 if lower_key(actual) == lower_k:
250 del self._real[i]
252 def __getitem__(self, item: K) -> V:
253 return self._keyed[lower_key(item)]
255 def get(self, key: K, /, default: Union[V, _T, None] = None) -> Union[V, _T, None]: # type: ignore[override]
256 try:
257 return self[key]
258 except KeyError:
259 if default is not None:
260 return default
261 elif self._default_factory is not None:
262 return self._default_factory()
263 else:
264 return None
266 def get_all(self, key: K) -> Iterator[V]:
267 lowered_key = lower_key(key)
268 for actual, value in self._real:
269 if lower_key(actual) == lowered_key:
270 yield value
272 def setdefault(self, key: K, default: Optional[V] = None) -> V:
273 try:
274 return self[key]
275 except KeyError:
276 if default is not None:
277 self[key] = default
278 return default
279 elif self._default_factory is not None:
280 value = self._default_factory()
281 self[key] = value
282 return value
283 else:
284 raise
287Name = bytes
288NameLike = Union[bytes, str]
289Section = tuple[bytes, ...]
290SectionLike = Union[bytes, str, tuple[Union[bytes, str], ...]]
291Value = bytes
292ValueLike = Union[bytes, str]
295class Config:
296 """A Git configuration."""
298 def get(self, section: SectionLike, name: NameLike) -> Value:
299 """Retrieve the contents of a configuration setting.
301 Args:
302 section: Tuple with section name and optional subsection name
303 name: Variable name
304 Returns:
305 Contents of the setting
306 Raises:
307 KeyError: if the value is not set
308 """
309 raise NotImplementedError(self.get)
311 def get_multivar(self, section: SectionLike, name: NameLike) -> Iterator[Value]:
312 """Retrieve the contents of a multivar configuration setting.
314 Args:
315 section: Tuple with section name and optional subsection namee
316 name: Variable name
317 Returns:
318 Contents of the setting as iterable
319 Raises:
320 KeyError: if the value is not set
321 """
322 raise NotImplementedError(self.get_multivar)
324 @overload
325 def get_boolean(
326 self, section: SectionLike, name: NameLike, default: bool
327 ) -> bool: ...
329 @overload
330 def get_boolean(self, section: SectionLike, name: NameLike) -> Optional[bool]: ...
332 def get_boolean(
333 self, section: SectionLike, name: NameLike, default: Optional[bool] = None
334 ) -> Optional[bool]:
335 """Retrieve a configuration setting as boolean.
337 Args:
338 section: Tuple with section name and optional subsection name
339 name: Name of the setting, including section and possible
340 subsection.
342 Returns:
343 Contents of the setting
344 """
345 try:
346 value = self.get(section, name)
347 except KeyError:
348 return default
349 if value.lower() == b"true":
350 return True
351 elif value.lower() == b"false":
352 return False
353 raise ValueError(f"not a valid boolean string: {value!r}")
355 def set(
356 self, section: SectionLike, name: NameLike, value: Union[ValueLike, bool]
357 ) -> None:
358 """Set a configuration value.
360 Args:
361 section: Tuple with section name and optional subsection namee
362 name: Name of the configuration value, including section
363 and optional subsection
364 value: value of the setting
365 """
366 raise NotImplementedError(self.set)
368 def items(self, section: SectionLike) -> Iterator[tuple[Name, Value]]:
369 """Iterate over the configuration pairs for a specific section.
371 Args:
372 section: Tuple with section name and optional subsection namee
373 Returns:
374 Iterator over (name, value) pairs
375 """
376 raise NotImplementedError(self.items)
378 def sections(self) -> Iterator[Section]:
379 """Iterate over the sections.
381 Returns: Iterator over section tuples
382 """
383 raise NotImplementedError(self.sections)
385 def has_section(self, name: Section) -> bool:
386 """Check if a specified section exists.
388 Args:
389 name: Name of section to check for
390 Returns:
391 boolean indicating whether the section exists
392 """
393 return name in self.sections()
396class ConfigDict(Config):
397 """Git configuration stored in a dictionary."""
399 def __init__(
400 self,
401 values: Union[
402 MutableMapping[Section, CaseInsensitiveOrderedMultiDict[Name, Value]], None
403 ] = None,
404 encoding: Union[str, None] = None,
405 ) -> None:
406 """Create a new ConfigDict."""
407 if encoding is None:
408 encoding = sys.getdefaultencoding()
409 self.encoding = encoding
410 self._values: CaseInsensitiveOrderedMultiDict[
411 Section, CaseInsensitiveOrderedMultiDict[Name, Value]
412 ] = CaseInsensitiveOrderedMultiDict.make(
413 values, default_factory=CaseInsensitiveOrderedMultiDict
414 )
416 def __repr__(self) -> str:
417 return f"{self.__class__.__name__}({self._values!r})"
419 def __eq__(self, other: object) -> bool:
420 return isinstance(other, self.__class__) and other._values == self._values
422 def __getitem__(self, key: Section) -> CaseInsensitiveOrderedMultiDict[Name, Value]:
423 return self._values.__getitem__(key)
425 def __setitem__(
426 self, key: Section, value: CaseInsensitiveOrderedMultiDict[Name, Value]
427 ) -> None:
428 return self._values.__setitem__(key, value)
430 def __delitem__(self, key: Section) -> None:
431 return self._values.__delitem__(key)
433 def __iter__(self) -> Iterator[Section]:
434 return self._values.__iter__()
436 def __len__(self) -> int:
437 return self._values.__len__()
439 def keys(self) -> KeysView[Section]:
440 return self._values.keys()
442 @classmethod
443 def _parse_setting(cls, name: str) -> tuple[str, Optional[str], str]:
444 parts = name.split(".")
445 if len(parts) == 3:
446 return (parts[0], parts[1], parts[2])
447 else:
448 return (parts[0], None, parts[1])
450 def _check_section_and_name(
451 self, section: SectionLike, name: NameLike
452 ) -> tuple[Section, Name]:
453 if not isinstance(section, tuple):
454 section = (section,)
456 checked_section = tuple(
457 [
458 subsection.encode(self.encoding)
459 if not isinstance(subsection, bytes)
460 else subsection
461 for subsection in section
462 ]
463 )
465 if not isinstance(name, bytes):
466 name = name.encode(self.encoding)
468 return checked_section, name
470 def get_multivar(self, section: SectionLike, name: NameLike) -> Iterator[Value]:
471 section, name = self._check_section_and_name(section, name)
473 if len(section) > 1:
474 try:
475 return self._values[section].get_all(name)
476 except KeyError:
477 pass
479 return self._values[(section[0],)].get_all(name)
481 def get(
482 self,
483 section: SectionLike,
484 name: NameLike,
485 ) -> Value:
486 section, name = self._check_section_and_name(section, name)
488 if len(section) > 1:
489 try:
490 return self._values[section][name]
491 except KeyError:
492 pass
494 return self._values[(section[0],)][name]
496 def set(
497 self,
498 section: SectionLike,
499 name: NameLike,
500 value: Union[ValueLike, bool],
501 ) -> None:
502 section, name = self._check_section_and_name(section, name)
504 if isinstance(value, bool):
505 value = b"true" if value else b"false"
507 if not isinstance(value, bytes):
508 value = value.encode(self.encoding)
510 section_dict = self._values.setdefault(section)
511 if hasattr(section_dict, "set"):
512 section_dict.set(name, value)
513 else:
514 section_dict[name] = value
516 def add(
517 self,
518 section: SectionLike,
519 name: NameLike,
520 value: Union[ValueLike, bool],
521 ) -> None:
522 """Add a value to a configuration setting, creating a multivar if needed."""
523 section, name = self._check_section_and_name(section, name)
525 if isinstance(value, bool):
526 value = b"true" if value else b"false"
528 if not isinstance(value, bytes):
529 value = value.encode(self.encoding)
531 self._values.setdefault(section)[name] = value
533 def items(self, section: SectionLike) -> Iterator[tuple[Name, Value]]:
534 section_bytes, _ = self._check_section_and_name(section, b"")
535 section_dict = self._values.get(section_bytes)
536 if section_dict is not None:
537 return iter(section_dict.items())
538 return iter([])
540 def sections(self) -> Iterator[Section]:
541 return iter(self._values.keys())
544def _format_string(value: bytes) -> bytes:
545 if (
546 value.startswith((b" ", b"\t"))
547 or value.endswith((b" ", b"\t"))
548 or b"#" in value
549 ):
550 return b'"' + _escape_value(value) + b'"'
551 else:
552 return _escape_value(value)
555_ESCAPE_TABLE = {
556 ord(b"\\"): ord(b"\\"),
557 ord(b'"'): ord(b'"'),
558 ord(b"n"): ord(b"\n"),
559 ord(b"t"): ord(b"\t"),
560 ord(b"b"): ord(b"\b"),
561}
562_COMMENT_CHARS = [ord(b"#"), ord(b";")]
563_WHITESPACE_CHARS = [ord(b"\t"), ord(b" ")]
566def _parse_string(value: bytes) -> bytes:
567 value = bytearray(value.strip())
568 ret = bytearray()
569 whitespace = bytearray()
570 in_quotes = False
571 i = 0
572 while i < len(value):
573 c = value[i]
574 if c == ord(b"\\"):
575 i += 1
576 if i >= len(value):
577 # Backslash at end of string - treat as literal backslash
578 if whitespace:
579 ret.extend(whitespace)
580 whitespace = bytearray()
581 ret.append(ord(b"\\"))
582 else:
583 try:
584 v = _ESCAPE_TABLE[value[i]]
585 if whitespace:
586 ret.extend(whitespace)
587 whitespace = bytearray()
588 ret.append(v)
589 except KeyError:
590 # Unknown escape sequence - treat backslash as literal and process next char normally
591 if whitespace:
592 ret.extend(whitespace)
593 whitespace = bytearray()
594 ret.append(ord(b"\\"))
595 i -= 1 # Reprocess the character after the backslash
596 elif c == ord(b'"'):
597 in_quotes = not in_quotes
598 elif c in _COMMENT_CHARS and not in_quotes:
599 # the rest of the line is a comment
600 break
601 elif c in _WHITESPACE_CHARS:
602 whitespace.append(c)
603 else:
604 if whitespace:
605 ret.extend(whitespace)
606 whitespace = bytearray()
607 ret.append(c)
608 i += 1
610 if in_quotes:
611 raise ValueError("missing end quote")
613 return bytes(ret)
616def _escape_value(value: bytes) -> bytes:
617 """Escape a value."""
618 value = value.replace(b"\\", b"\\\\")
619 value = value.replace(b"\r", b"\\r")
620 value = value.replace(b"\n", b"\\n")
621 value = value.replace(b"\t", b"\\t")
622 value = value.replace(b'"', b'\\"')
623 return value
626def _check_variable_name(name: bytes) -> bool:
627 for i in range(len(name)):
628 c = name[i : i + 1]
629 if not c.isalnum() and c != b"-":
630 return False
631 return True
634def _check_section_name(name: bytes) -> bool:
635 for i in range(len(name)):
636 c = name[i : i + 1]
637 if not c.isalnum() and c not in (b"-", b"."):
638 return False
639 return True
642def _strip_comments(line: bytes) -> bytes:
643 comment_bytes = {ord(b"#"), ord(b";")}
644 quote = ord(b'"')
645 string_open = False
646 # Normalize line to bytearray for simple 2/3 compatibility
647 for i, character in enumerate(bytearray(line)):
648 # Comment characters outside balanced quotes denote comment start
649 if character == quote:
650 string_open = not string_open
651 elif not string_open and character in comment_bytes:
652 return line[:i]
653 return line
656def _is_line_continuation(value: bytes) -> bool:
657 """Check if a value ends with a line continuation backslash.
659 A line continuation occurs when a line ends with a backslash that is:
660 1. Not escaped (not preceded by another backslash)
661 2. Not within quotes
663 Args:
664 value: The value to check
666 Returns:
667 True if the value ends with a line continuation backslash
668 """
669 if not value.endswith((b"\\\n", b"\\\r\n")):
670 return False
672 # Remove only the newline characters, keep the content including the backslash
673 if value.endswith(b"\\\r\n"):
674 content = value[:-2] # Remove \r\n, keep the \
675 else:
676 content = value[:-1] # Remove \n, keep the \
678 if not content.endswith(b"\\"):
679 return False
681 # Count consecutive backslashes at the end
682 backslash_count = 0
683 for i in range(len(content) - 1, -1, -1):
684 if content[i : i + 1] == b"\\":
685 backslash_count += 1
686 else:
687 break
689 # If we have an odd number of backslashes, the last one is a line continuation
690 # If we have an even number, they are all escaped and there's no continuation
691 return backslash_count % 2 == 1
694def _parse_section_header_line(line: bytes) -> tuple[Section, bytes]:
695 # Parse section header ("[bla]")
696 line = _strip_comments(line).rstrip()
697 in_quotes = False
698 escaped = False
699 for i, c in enumerate(line):
700 if escaped:
701 escaped = False
702 continue
703 if c == ord(b'"'):
704 in_quotes = not in_quotes
705 if c == ord(b"\\"):
706 escaped = True
707 if c == ord(b"]") and not in_quotes:
708 last = i
709 break
710 else:
711 raise ValueError("expected trailing ]")
712 pts = line[1:last].split(b" ", 1)
713 line = line[last + 1 :]
714 section: Section
715 if len(pts) == 2:
716 # Handle subsections - Git allows more complex syntax for certain sections like includeIf
717 if pts[1][:1] == b'"' and pts[1][-1:] == b'"':
718 # Standard quoted subsection
719 pts[1] = pts[1][1:-1]
720 elif pts[0] == b"includeIf":
721 # Special handling for includeIf sections which can have complex conditions
722 # Git allows these without strict quote validation
723 pts[1] = pts[1].strip()
724 if pts[1][:1] == b'"' and pts[1][-1:] == b'"':
725 pts[1] = pts[1][1:-1]
726 else:
727 # Other sections must have quoted subsections
728 raise ValueError(f"Invalid subsection {pts[1]!r}")
729 if not _check_section_name(pts[0]):
730 raise ValueError(f"invalid section name {pts[0]!r}")
731 section = (pts[0], pts[1])
732 else:
733 if not _check_section_name(pts[0]):
734 raise ValueError(f"invalid section name {pts[0]!r}")
735 pts = pts[0].split(b".", 1)
736 if len(pts) == 2:
737 section = (pts[0], pts[1])
738 else:
739 section = (pts[0],)
740 return section, line
743class ConfigFile(ConfigDict):
744 """A Git configuration file, like .git/config or ~/.gitconfig."""
746 def __init__(
747 self,
748 values: Union[
749 MutableMapping[Section, CaseInsensitiveOrderedMultiDict[Name, Value]], None
750 ] = None,
751 encoding: Union[str, None] = None,
752 ) -> None:
753 super().__init__(values=values, encoding=encoding)
754 self.path: Optional[str] = None
755 self._included_paths: set[str] = set() # Track included files to prevent cycles
757 @classmethod
758 def from_file(
759 cls,
760 f: BinaryIO,
761 *,
762 config_dir: Optional[str] = None,
763 included_paths: Optional[set[str]] = None,
764 include_depth: int = 0,
765 max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH,
766 file_opener: Optional[FileOpener] = None,
767 condition_matchers: Optional[dict[str, ConditionMatcher]] = None,
768 ) -> "ConfigFile":
769 """Read configuration from a file-like object.
771 Args:
772 f: File-like object to read from
773 config_dir: Directory containing the config file (for relative includes)
774 included_paths: Set of already included paths (to prevent cycles)
775 include_depth: Current include depth (to prevent infinite recursion)
776 max_include_depth: Maximum allowed include depth
777 file_opener: Optional callback to open included files
778 condition_matchers: Optional dict of condition matchers for includeIf
779 """
780 if include_depth > max_include_depth:
781 # Prevent excessive recursion
782 raise ValueError(f"Maximum include depth ({max_include_depth}) exceeded")
784 ret = cls()
785 if included_paths is not None:
786 ret._included_paths = included_paths.copy()
788 section: Optional[Section] = None
789 setting = None
790 continuation = None
791 for lineno, line in enumerate(f.readlines()):
792 if lineno == 0 and line.startswith(b"\xef\xbb\xbf"):
793 line = line[3:]
794 line = line.lstrip()
795 if setting is None:
796 if len(line) > 0 and line[:1] == b"[":
797 section, line = _parse_section_header_line(line)
798 ret._values.setdefault(section)
799 if _strip_comments(line).strip() == b"":
800 continue
801 if section is None:
802 raise ValueError(f"setting {line!r} without section")
803 try:
804 setting, value = line.split(b"=", 1)
805 except ValueError:
806 setting = line
807 value = b"true"
808 setting = setting.strip()
809 if not _check_variable_name(setting):
810 raise ValueError(f"invalid variable name {setting!r}")
811 if _is_line_continuation(value):
812 if value.endswith(b"\\\r\n"):
813 continuation = value[:-3]
814 else:
815 continuation = value[:-2]
816 else:
817 continuation = None
818 value = _parse_string(value)
819 ret._values[section][setting] = value
821 # Process include/includeIf directives
822 ret._handle_include_directive(
823 section,
824 setting,
825 value,
826 config_dir=config_dir,
827 include_depth=include_depth,
828 max_include_depth=max_include_depth,
829 file_opener=file_opener,
830 condition_matchers=condition_matchers,
831 )
833 setting = None
834 else: # continuation line
835 assert continuation is not None
836 if _is_line_continuation(line):
837 if line.endswith(b"\\\r\n"):
838 continuation += line[:-3]
839 else:
840 continuation += line[:-2]
841 else:
842 continuation += line
843 value = _parse_string(continuation)
844 assert section is not None # Already checked above
845 ret._values[section][setting] = value
847 # Process include/includeIf directives
848 ret._handle_include_directive(
849 section,
850 setting,
851 value,
852 config_dir=config_dir,
853 include_depth=include_depth,
854 max_include_depth=max_include_depth,
855 file_opener=file_opener,
856 condition_matchers=condition_matchers,
857 )
859 continuation = None
860 setting = None
861 return ret
863 def _handle_include_directive(
864 self,
865 section: Optional[Section],
866 setting: bytes,
867 value: bytes,
868 *,
869 config_dir: Optional[str],
870 include_depth: int,
871 max_include_depth: int,
872 file_opener: Optional[FileOpener],
873 condition_matchers: Optional[dict[str, ConditionMatcher]],
874 ) -> None:
875 """Handle include/includeIf directives during config parsing."""
876 if (
877 section is not None
878 and setting == b"path"
879 and (
880 section[0].lower() == b"include"
881 or (len(section) > 1 and section[0].lower() == b"includeif")
882 )
883 ):
884 self._process_include(
885 section,
886 value,
887 config_dir=config_dir,
888 include_depth=include_depth,
889 max_include_depth=max_include_depth,
890 file_opener=file_opener,
891 condition_matchers=condition_matchers,
892 )
894 def _process_include(
895 self,
896 section: Section,
897 path_value: bytes,
898 *,
899 config_dir: Optional[str],
900 include_depth: int,
901 max_include_depth: int,
902 file_opener: Optional[FileOpener],
903 condition_matchers: Optional[dict[str, ConditionMatcher]],
904 ) -> None:
905 """Process an include or includeIf directive."""
906 path_str = path_value.decode(self.encoding, errors="replace")
908 # Handle includeIf conditions
909 if len(section) > 1 and section[0].lower() == b"includeif":
910 condition = section[1].decode(self.encoding, errors="replace")
911 if not self._evaluate_includeif_condition(
912 condition, config_dir, condition_matchers
913 ):
914 return
916 # Resolve the include path
917 include_path = self._resolve_include_path(path_str, config_dir)
918 if not include_path:
919 return
921 # Check for circular includes
922 try:
923 abs_path = str(Path(include_path).resolve())
924 except (OSError, ValueError) as e:
925 # Invalid path - log and skip
926 logger.debug("Invalid include path %r: %s", include_path, e)
927 return
928 if abs_path in self._included_paths:
929 return
931 # Load and merge the included file
932 try:
933 # Use provided file opener or default to GitFile
934 opener: FileOpener
935 if file_opener is None:
937 def opener(path: Union[str, os.PathLike]) -> BinaryIO:
938 return cast(BinaryIO, GitFile(path, "rb"))
939 else:
940 opener = file_opener
942 f = opener(include_path)
943 except (OSError, ValueError) as e:
944 # Git silently ignores missing or unreadable include files
945 # Log for debugging purposes
946 logger.debug("Invalid include path %r: %s", include_path, e)
947 else:
948 with f as included_file:
949 # Track this path to prevent cycles
950 self._included_paths.add(abs_path)
952 # Parse the included file
953 included_config = ConfigFile.from_file(
954 included_file,
955 config_dir=os.path.dirname(include_path),
956 included_paths=self._included_paths,
957 include_depth=include_depth + 1,
958 max_include_depth=max_include_depth,
959 file_opener=file_opener,
960 condition_matchers=condition_matchers,
961 )
963 # Merge the included configuration
964 self._merge_config(included_config)
966 def _merge_config(self, other: "ConfigFile") -> None:
967 """Merge another config file into this one."""
968 for section, values in other._values.items():
969 if section not in self._values:
970 self._values[section] = CaseInsensitiveOrderedMultiDict()
971 for key, value in values.items():
972 self._values[section][key] = value
974 def _resolve_include_path(
975 self, path: str, config_dir: Optional[str]
976 ) -> Optional[str]:
977 """Resolve an include path to an absolute path."""
978 # Expand ~ to home directory
979 path = os.path.expanduser(path)
981 # If path is relative and we have a config directory, make it relative to that
982 if not os.path.isabs(path) and config_dir:
983 path = os.path.join(config_dir, path)
985 return path
987 def _evaluate_includeif_condition(
988 self,
989 condition: str,
990 config_dir: Optional[str] = None,
991 condition_matchers: Optional[dict[str, ConditionMatcher]] = None,
992 ) -> bool:
993 """Evaluate an includeIf condition."""
994 # Try custom matchers first if provided
995 if condition_matchers:
996 for prefix, matcher in condition_matchers.items():
997 if condition.startswith(prefix):
998 return matcher(condition[len(prefix) :])
1000 # Fall back to built-in matchers
1001 if condition.startswith("hasconfig:"):
1002 return self._evaluate_hasconfig_condition(condition[10:])
1003 else:
1004 # Unknown condition type - log and ignore (Git behavior)
1005 logger.debug("Unknown includeIf condition: %r", condition)
1006 return False
1008 def _evaluate_hasconfig_condition(self, condition: str) -> bool:
1009 """Evaluate a hasconfig condition.
1011 Format: hasconfig:config.key:pattern
1012 Example: hasconfig:remote.*.url:ssh://org-*@github.com/**
1013 """
1014 # Split on the first colon to separate config key from pattern
1015 parts = condition.split(":", 1)
1016 if len(parts) != 2:
1017 logger.debug("Invalid hasconfig condition format: %r", condition)
1018 return False
1020 config_key, pattern = parts
1022 # Parse the config key to get section and name
1023 key_parts = config_key.split(".", 2)
1024 if len(key_parts) < 2:
1025 logger.debug("Invalid hasconfig config key: %r", config_key)
1026 return False
1028 # Handle wildcards in section names (e.g., remote.*)
1029 if len(key_parts) == 3 and key_parts[1] == "*":
1030 # Match any subsection
1031 section_prefix = key_parts[0].encode(self.encoding)
1032 name = key_parts[2].encode(self.encoding)
1034 # Check all sections that match the pattern
1035 for section in self.sections():
1036 if len(section) == 2 and section[0] == section_prefix:
1037 try:
1038 values = list(self.get_multivar(section, name))
1039 for value in values:
1040 if self._match_hasconfig_pattern(value, pattern):
1041 return True
1042 except KeyError:
1043 continue
1044 else:
1045 # Direct section lookup
1046 if len(key_parts) == 2:
1047 section = (key_parts[0].encode(self.encoding),)
1048 name = key_parts[1].encode(self.encoding)
1049 else:
1050 section = (
1051 key_parts[0].encode(self.encoding),
1052 key_parts[1].encode(self.encoding),
1053 )
1054 name = key_parts[2].encode(self.encoding)
1056 try:
1057 values = list(self.get_multivar(section, name))
1058 for value in values:
1059 if self._match_hasconfig_pattern(value, pattern):
1060 return True
1061 except KeyError:
1062 pass
1064 return False
1066 def _match_hasconfig_pattern(self, value: bytes, pattern: str) -> bool:
1067 """Match a config value against a hasconfig pattern.
1069 Supports simple glob patterns like ``*`` and ``**``.
1070 """
1071 value_str = value.decode(self.encoding, errors="replace")
1072 return match_glob_pattern(value_str, pattern)
1074 @classmethod
1075 def from_path(
1076 cls,
1077 path: Union[str, os.PathLike],
1078 *,
1079 max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH,
1080 file_opener: Optional[FileOpener] = None,
1081 condition_matchers: Optional[dict[str, ConditionMatcher]] = None,
1082 ) -> "ConfigFile":
1083 """Read configuration from a file on disk.
1085 Args:
1086 path: Path to the configuration file
1087 max_include_depth: Maximum allowed include depth
1088 file_opener: Optional callback to open included files
1089 condition_matchers: Optional dict of condition matchers for includeIf
1090 """
1091 abs_path = os.fspath(path)
1092 config_dir = os.path.dirname(abs_path)
1094 # Use provided file opener or default to GitFile
1095 opener: FileOpener
1096 if file_opener is None:
1098 def opener(p: Union[str, os.PathLike]) -> BinaryIO:
1099 return cast(BinaryIO, GitFile(p, "rb"))
1100 else:
1101 opener = file_opener
1103 with opener(abs_path) as f:
1104 ret = cls.from_file(
1105 f,
1106 config_dir=config_dir,
1107 max_include_depth=max_include_depth,
1108 file_opener=file_opener,
1109 condition_matchers=condition_matchers,
1110 )
1111 ret.path = abs_path
1112 return ret
1114 def write_to_path(self, path: Optional[Union[str, os.PathLike]] = None) -> None:
1115 """Write configuration to a file on disk."""
1116 if path is None:
1117 if self.path is None:
1118 raise ValueError("No path specified and no default path available")
1119 path_to_use: Union[str, os.PathLike] = self.path
1120 else:
1121 path_to_use = path
1122 with GitFile(path_to_use, "wb") as f:
1123 self.write_to_file(f)
1125 def write_to_file(self, f: Union[IO[bytes], _GitFile]) -> None:
1126 """Write configuration to a file-like object."""
1127 for section, values in self._values.items():
1128 try:
1129 section_name, subsection_name = section
1130 except ValueError:
1131 (section_name,) = section
1132 subsection_name = None
1133 if subsection_name is None:
1134 f.write(b"[" + section_name + b"]\n")
1135 else:
1136 f.write(b"[" + section_name + b' "' + subsection_name + b'"]\n')
1137 for key, value in values.items():
1138 value = _format_string(value)
1139 f.write(b"\t" + key + b" = " + value + b"\n")
1142def get_xdg_config_home_path(*path_segments: str) -> str:
1143 xdg_config_home = os.environ.get(
1144 "XDG_CONFIG_HOME",
1145 os.path.expanduser("~/.config/"),
1146 )
1147 return os.path.join(xdg_config_home, *path_segments)
1150def _find_git_in_win_path() -> Iterator[str]:
1151 for exe in ("git.exe", "git.cmd"):
1152 for path in os.environ.get("PATH", "").split(";"):
1153 if os.path.exists(os.path.join(path, exe)):
1154 # in windows native shells (powershell/cmd) exe path is
1155 # .../Git/bin/git.exe or .../Git/cmd/git.exe
1156 #
1157 # in git-bash exe path is .../Git/mingw64/bin/git.exe
1158 git_dir, _bin_dir = os.path.split(path)
1159 yield git_dir
1160 parent_dir, basename = os.path.split(git_dir)
1161 if basename == "mingw32" or basename == "mingw64":
1162 yield parent_dir
1163 break
1166def _find_git_in_win_reg() -> Iterator[str]:
1167 import platform
1168 import winreg
1170 if platform.machine() == "AMD64":
1171 subkey = (
1172 "SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\"
1173 "CurrentVersion\\Uninstall\\Git_is1"
1174 )
1175 else:
1176 subkey = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Git_is1"
1178 for key in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE): # type: ignore
1179 with suppress(OSError):
1180 with winreg.OpenKey(key, subkey) as k: # type: ignore
1181 val, typ = winreg.QueryValueEx(k, "InstallLocation") # type: ignore
1182 if typ == winreg.REG_SZ: # type: ignore
1183 yield val
1186# There is no set standard for system config dirs on windows. We try the
1187# following:
1188# - %PROGRAMFILES%/Git/etc/gitconfig - Git for Windows (msysgit) config dir
1189# Used if CGit installation (Git/bin/git.exe) is found in PATH in the
1190# system registry
1191def get_win_system_paths() -> Iterator[str]:
1192 """Get current Windows system Git config paths.
1194 Only returns the current Git for Windows config location, not legacy paths.
1195 """
1196 # Try to find Git installation from PATH first
1197 for git_dir in _find_git_in_win_path():
1198 yield os.path.join(git_dir, "etc", "gitconfig")
1199 return # Only use the first found path
1201 # Fall back to registry if not found in PATH
1202 for git_dir in _find_git_in_win_reg():
1203 yield os.path.join(git_dir, "etc", "gitconfig")
1204 return # Only use the first found path
1207def get_win_legacy_system_paths() -> Iterator[str]:
1208 """Get legacy Windows system Git config paths.
1210 Returns all possible config paths including deprecated locations.
1211 This function can be used for diagnostics or migration purposes.
1212 """
1213 # Include deprecated PROGRAMDATA location
1214 if "PROGRAMDATA" in os.environ:
1215 yield os.path.join(os.environ["PROGRAMDATA"], "Git", "config")
1217 # Include all Git installations found
1218 for git_dir in _find_git_in_win_path():
1219 yield os.path.join(git_dir, "etc", "gitconfig")
1220 for git_dir in _find_git_in_win_reg():
1221 yield os.path.join(git_dir, "etc", "gitconfig")
1224class StackedConfig(Config):
1225 """Configuration which reads from multiple config files.."""
1227 def __init__(
1228 self, backends: list[ConfigFile], writable: Optional[ConfigFile] = None
1229 ) -> None:
1230 self.backends = backends
1231 self.writable = writable
1233 def __repr__(self) -> str:
1234 return f"<{self.__class__.__name__} for {self.backends!r}>"
1236 @classmethod
1237 def default(cls) -> "StackedConfig":
1238 return cls(cls.default_backends())
1240 @classmethod
1241 def default_backends(cls) -> list[ConfigFile]:
1242 """Retrieve the default configuration.
1244 See git-config(1) for details on the files searched.
1245 """
1246 paths = []
1248 # Handle GIT_CONFIG_GLOBAL - overrides user config paths
1249 try:
1250 paths.append(os.environ["GIT_CONFIG_GLOBAL"])
1251 except KeyError:
1252 paths.append(os.path.expanduser("~/.gitconfig"))
1253 paths.append(get_xdg_config_home_path("git", "config"))
1255 # Handle GIT_CONFIG_SYSTEM and GIT_CONFIG_NOSYSTEM
1256 try:
1257 paths.append(os.environ["GIT_CONFIG_SYSTEM"])
1258 except KeyError:
1259 if "GIT_CONFIG_NOSYSTEM" not in os.environ:
1260 paths.append("/etc/gitconfig")
1261 if sys.platform == "win32":
1262 paths.extend(get_win_system_paths())
1264 logger.debug("Loading gitconfig from paths: %s", paths)
1266 backends = []
1267 for path in paths:
1268 try:
1269 cf = ConfigFile.from_path(path)
1270 logger.debug("Successfully loaded gitconfig from: %s", path)
1271 except FileNotFoundError:
1272 logger.debug("Gitconfig file not found: %s", path)
1273 continue
1274 backends.append(cf)
1275 return backends
1277 def get(self, section: SectionLike, name: NameLike) -> Value:
1278 if not isinstance(section, tuple):
1279 section = (section,)
1280 for backend in self.backends:
1281 try:
1282 return backend.get(section, name)
1283 except KeyError:
1284 pass
1285 raise KeyError(name)
1287 def get_multivar(self, section: SectionLike, name: NameLike) -> Iterator[Value]:
1288 if not isinstance(section, tuple):
1289 section = (section,)
1290 for backend in self.backends:
1291 try:
1292 yield from backend.get_multivar(section, name)
1293 except KeyError:
1294 pass
1296 def set(
1297 self, section: SectionLike, name: NameLike, value: Union[ValueLike, bool]
1298 ) -> None:
1299 if self.writable is None:
1300 raise NotImplementedError(self.set)
1301 return self.writable.set(section, name, value)
1303 def sections(self) -> Iterator[Section]:
1304 seen = set()
1305 for backend in self.backends:
1306 for section in backend.sections():
1307 if section not in seen:
1308 seen.add(section)
1309 yield section
1312def read_submodules(
1313 path: Union[str, os.PathLike],
1314) -> Iterator[tuple[bytes, bytes, bytes]]:
1315 """Read a .gitmodules file."""
1316 cfg = ConfigFile.from_path(path)
1317 return parse_submodules(cfg)
1320def parse_submodules(config: ConfigFile) -> Iterator[tuple[bytes, bytes, bytes]]:
1321 """Parse a gitmodules GitConfig file, returning submodules.
1323 Args:
1324 config: A `ConfigFile`
1325 Returns:
1326 list of tuples (submodule path, url, name),
1327 where name is quoted part of the section's name.
1328 """
1329 for section in config.sections():
1330 section_kind, section_name = section
1331 if section_kind == b"submodule":
1332 try:
1333 sm_path = config.get(section, b"path")
1334 sm_url = config.get(section, b"url")
1335 yield (sm_path, sm_url, section_name)
1336 except KeyError:
1337 # If either path or url is missing, just ignore this
1338 # submodule entry and move on to the next one. This is
1339 # how git itself handles malformed .gitmodule entries.
1340 pass
1343def iter_instead_of(config: Config, push: bool = False) -> Iterable[tuple[str, str]]:
1344 """Iterate over insteadOf / pushInsteadOf values."""
1345 for section in config.sections():
1346 if section[0] != b"url":
1347 continue
1348 replacement = section[1]
1349 try:
1350 needles = list(config.get_multivar(section, "insteadOf"))
1351 except KeyError:
1352 needles = []
1353 if push:
1354 try:
1355 needles += list(config.get_multivar(section, "pushInsteadOf"))
1356 except KeyError:
1357 pass
1358 for needle in needles:
1359 assert isinstance(needle, bytes)
1360 yield needle.decode("utf-8"), replacement.decode("utf-8")
1363def apply_instead_of(config: Config, orig_url: str, push: bool = False) -> str:
1364 """Apply insteadOf / pushInsteadOf to a URL."""
1365 longest_needle = ""
1366 updated_url = orig_url
1367 for needle, replacement in iter_instead_of(config, push):
1368 if not orig_url.startswith(needle):
1369 continue
1370 if len(longest_needle) < len(needle):
1371 longest_needle = needle
1372 updated_url = replacement + orig_url[len(needle) :]
1373 return updated_url