Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/dulwich/config.py: 61%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# config.py - Reading and writing Git config files
2# Copyright (C) 2011-2013 Jelmer Vernooij <jelmer@jelmer.uk>
3#
4# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
5# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
6# General Public License as published by the Free Software Foundation; version 2.0
7# or (at your option) any later version. You can redistribute it and/or
8# modify it under the terms of either of these two licenses.
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15#
16# You should have received a copy of the licenses; if not, see
17# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
18# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
19# License, Version 2.0.
20#
22"""Reading and writing Git configuration files.
24Todo:
25 * preserve formatting when updating configuration files
26"""
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 "get_win_legacy_system_paths",
42 "get_win_system_paths",
43 "get_xdg_config_home_path",
44 "iter_instead_of",
45 "lower_key",
46 "match_glob_pattern",
47 "parse_submodules",
48 "read_submodules",
49]
51import logging
52import os
53import re
54import sys
55from collections.abc import (
56 Callable,
57 ItemsView,
58 Iterable,
59 Iterator,
60 KeysView,
61 Mapping,
62 MutableMapping,
63 ValuesView,
64)
65from contextlib import suppress
66from pathlib import Path
67from typing import (
68 IO,
69 Generic,
70 TypeVar,
71 overload,
72)
74from .file import GitFile, _GitFile
76ConfigKey = str | bytes | tuple[str | bytes, ...]
77ConfigValue = str | bytes | bool | int
79logger = logging.getLogger(__name__)
81# Type for file opener callback
82FileOpener = Callable[[str | os.PathLike[str]], IO[bytes]]
84# Type for includeIf condition matcher
85# Takes the condition value (e.g., "main" for onbranch:main) and returns bool
86ConditionMatcher = Callable[[str], bool]
88# Security limits for include files
89MAX_INCLUDE_FILE_SIZE = 1024 * 1024 # 1MB max for included config files
90DEFAULT_MAX_INCLUDE_DEPTH = 10 # Maximum recursion depth for includes
93def _match_gitdir_pattern(
94 path: bytes, pattern: bytes, ignorecase: bool = False
95) -> bool:
96 """Simple gitdir pattern matching for includeIf conditions.
98 This handles the basic gitdir patterns used in includeIf directives.
99 """
100 # Convert to strings for easier manipulation
101 path_str = path.decode("utf-8", errors="replace")
102 pattern_str = pattern.decode("utf-8", errors="replace")
104 # Normalize paths to use forward slashes for consistent matching
105 path_str = path_str.replace("\\", "/")
106 pattern_str = pattern_str.replace("\\", "/")
108 if ignorecase:
109 path_str = path_str.lower()
110 pattern_str = pattern_str.lower()
112 # Handle the common cases for gitdir patterns
113 if pattern_str.startswith("**/") and pattern_str.endswith("/**"):
114 # Pattern like **/dirname/** should match any path containing dirname
115 dirname = pattern_str[3:-3] # Remove **/ and /**
116 # Check if path contains the directory name as a path component
117 return ("/" + dirname + "/") in path_str or path_str.endswith("/" + dirname)
118 elif pattern_str.startswith("**/"):
119 # Pattern like **/filename
120 suffix = pattern_str[3:] # Remove **/
121 return suffix in path_str or path_str.endswith("/" + suffix)
122 elif pattern_str.endswith("/**"):
123 # Pattern like /path/to/dir/** should match /path/to/dir and any subdirectory
124 base_pattern = pattern_str[:-3] # Remove /**
125 return path_str == base_pattern or path_str.startswith(base_pattern + "/")
126 elif "**" in pattern_str:
127 # Handle patterns with ** in the middle
128 parts = pattern_str.split("**")
129 if len(parts) == 2:
130 prefix, suffix = parts
131 # Path must start with prefix and end with suffix (if any)
132 if prefix and not path_str.startswith(prefix):
133 return False
134 if suffix and not path_str.endswith(suffix):
135 return False
136 return True
138 # Direct match or simple glob pattern
139 if "*" in pattern_str or "?" in pattern_str or "[" in pattern_str:
140 import fnmatch
142 return fnmatch.fnmatch(path_str, pattern_str)
143 else:
144 return path_str == pattern_str
147def match_glob_pattern(value: str, pattern: str) -> bool:
148 r"""Match a value against a glob pattern.
150 Supports simple glob patterns like ``*`` and ``**``.
152 Raises:
153 ValueError: If the pattern is invalid
154 """
155 # Convert glob pattern to regex
156 pattern_escaped = re.escape(pattern)
157 # Replace escaped \*\* with .* (match anything)
158 pattern_escaped = pattern_escaped.replace(r"\*\*", ".*")
159 # Replace escaped \* with [^/]* (match anything except /)
160 pattern_escaped = pattern_escaped.replace(r"\*", "[^/]*")
161 # Anchor the pattern
162 pattern_regex = f"^{pattern_escaped}$"
164 try:
165 return bool(re.match(pattern_regex, value))
166 except re.error as e:
167 raise ValueError(f"Invalid glob pattern {pattern!r}: {e}")
170def lower_key(key: ConfigKey) -> ConfigKey:
171 """Convert a config key to lowercase, preserving subsection case.
173 Args:
174 key: Configuration key (str, bytes, or tuple)
176 Returns:
177 Key with section names lowercased, subsection names preserved
179 Raises:
180 TypeError: If key is not str, bytes, or tuple
181 """
182 if isinstance(key, (bytes, str)):
183 return key.lower()
185 if isinstance(key, tuple):
186 # For config sections, only lowercase the section name (first element)
187 # but preserve the case of subsection names (remaining elements)
188 if len(key) > 0:
189 first = key[0]
190 assert isinstance(first, (bytes, str))
191 return (first.lower(), *key[1:])
192 return key
194 raise TypeError(key)
197K = TypeVar("K", bound=ConfigKey) # Key type must be ConfigKey
198V = TypeVar("V") # Value type
199_T = TypeVar("_T") # For get() default parameter
202class CaseInsensitiveOrderedMultiDict(MutableMapping[K, V], Generic[K, V]):
203 """A case-insensitive ordered dictionary that can store multiple values per key.
205 This class maintains the order of insertions and allows multiple values
206 for the same key. Keys are compared case-insensitively.
207 """
209 def __init__(self, default_factory: Callable[[], V] | None = None) -> None:
210 """Initialize a CaseInsensitiveOrderedMultiDict.
212 Args:
213 default_factory: Optional factory function for default values
214 """
215 self._real: list[tuple[K, V]] = []
216 self._keyed: dict[ConfigKey, V] = {}
217 self._default_factory = default_factory
219 @classmethod
220 def make(
221 cls,
222 dict_in: "MutableMapping[K, V] | CaseInsensitiveOrderedMultiDict[K, V] | None" = None,
223 default_factory: Callable[[], V] | None = None,
224 ) -> "CaseInsensitiveOrderedMultiDict[K, V]":
225 """Create a CaseInsensitiveOrderedMultiDict from an existing mapping.
227 Args:
228 dict_in: Optional mapping to initialize from
229 default_factory: Optional factory function for default values
231 Returns:
232 New CaseInsensitiveOrderedMultiDict instance
234 Raises:
235 TypeError: If dict_in is not a mapping or None
236 """
237 if isinstance(dict_in, cls):
238 return dict_in
240 out = cls(default_factory=default_factory)
242 if dict_in is None:
243 return out
245 if not isinstance(dict_in, MutableMapping):
246 raise TypeError
248 for key, value in dict_in.items():
249 out[key] = value
251 return out
253 def __len__(self) -> int:
254 """Return the number of unique keys in the dictionary."""
255 return len(self._keyed)
257 def keys(self) -> KeysView[K]:
258 """Return a view of the dictionary's keys."""
259 # Return a view of the original keys (not lowercased)
260 # We need to deduplicate since _real can have duplicates
261 seen = set()
262 unique_keys = []
263 for k, _ in self._real:
264 lower = lower_key(k)
265 if lower not in seen:
266 seen.add(lower)
267 unique_keys.append(k)
268 from collections.abc import KeysView as ABCKeysView
270 class UniqueKeysView(ABCKeysView[K]):
271 def __init__(self, keys: list[K]):
272 self._keys = keys
274 def __contains__(self, key: object) -> bool:
275 return key in self._keys
277 def __iter__(self) -> Iterator[K]:
278 return iter(self._keys)
280 def __len__(self) -> int:
281 return len(self._keys)
283 return UniqueKeysView(unique_keys)
285 def items(self) -> ItemsView[K, V]:
286 """Return a view of the dictionary's (key, value) pairs in insertion order."""
288 # Return a view that iterates over the real list to preserve order
289 class OrderedItemsView(ItemsView[K, V]):
290 """Items view that preserves insertion order."""
292 def __init__(self, mapping: CaseInsensitiveOrderedMultiDict[K, V]):
293 self._mapping = mapping
295 def __iter__(self) -> Iterator[tuple[K, V]]:
296 return iter(self._mapping._real)
298 def __len__(self) -> int:
299 return len(self._mapping._real)
301 def __contains__(self, item: object) -> bool:
302 if not isinstance(item, tuple) or len(item) != 2:
303 return False
304 key, value = item
305 return any(k == key and v == value for k, v in self._mapping._real)
307 return OrderedItemsView(self)
309 def __iter__(self) -> Iterator[K]:
310 """Iterate over the dictionary's keys."""
311 # Return iterator over original keys (not lowercased), deduplicated
312 seen = set()
313 for k, _ in self._real:
314 lower = lower_key(k)
315 if lower not in seen:
316 seen.add(lower)
317 yield k
319 def values(self) -> ValuesView[V]:
320 """Return a view of the dictionary's values."""
321 return self._keyed.values()
323 def __setitem__(self, key: K, value: V) -> None:
324 """Set a value for a key, appending to existing values."""
325 self._real.append((key, value))
326 self._keyed[lower_key(key)] = value
328 def set(self, key: K, value: V) -> None:
329 """Set a value for a key, replacing all existing values.
331 Args:
332 key: The key to set
333 value: The value to set
334 """
335 # This method replaces all existing values for the key
336 lower = lower_key(key)
337 self._real = [(k, v) for k, v in self._real if lower_key(k) != lower]
338 self._real.append((key, value))
339 self._keyed[lower] = value
341 def __delitem__(self, key: K) -> None:
342 """Delete all values for a key.
344 Raises:
345 KeyError: If the key is not found
346 """
347 lower_k = lower_key(key)
348 del self._keyed[lower_k]
349 for i, (actual, unused_value) in reversed(list(enumerate(self._real))):
350 if lower_key(actual) == lower_k:
351 del self._real[i]
353 def __getitem__(self, item: K) -> V:
354 """Get the last value for a key.
356 Raises:
357 KeyError: If the key is not found
358 """
359 return self._keyed[lower_key(item)]
361 def get(self, key: K, /, default: V | _T | None = None) -> V | _T | None: # type: ignore[override]
362 """Get the last value for a key, or a default if not found.
364 Args:
365 key: The key to look up
366 default: Default value to return if key not found
368 Returns:
369 The value for the key, or default/default_factory result if not found
370 """
371 try:
372 return self[key]
373 except KeyError:
374 if default is not None:
375 return default
376 elif self._default_factory is not None:
377 return self._default_factory()
378 else:
379 return None
381 def get_all(self, key: K) -> Iterator[V]:
382 """Get all values for a key in insertion order.
384 Args:
385 key: The key to look up
387 Returns:
388 Iterator of all values for the key
389 """
390 lowered_key = lower_key(key)
391 for actual, value in self._real:
392 if lower_key(actual) == lowered_key:
393 yield value
395 def setdefault(self, key: K, default: V | None = None) -> V:
396 """Get value for key, setting it to default if not present.
398 Args:
399 key: The key to look up
400 default: Default value to set if key not found
402 Returns:
403 The existing value or the newly set default
405 Raises:
406 KeyError: If key not found and no default or default_factory
407 """
408 try:
409 return self[key]
410 except KeyError:
411 if default is not None:
412 self[key] = default
413 return default
414 elif self._default_factory is not None:
415 value = self._default_factory()
416 self[key] = value
417 return value
418 else:
419 raise
422Name = bytes
423NameLike = bytes | str
424Section = tuple[bytes, ...]
425SectionLike = bytes | str | tuple[bytes | str, ...]
426Value = bytes
427ValueLike = bytes | str
430class Config:
431 """A Git configuration."""
433 def get(self, section: SectionLike, name: NameLike) -> Value:
434 """Retrieve the contents of a configuration setting.
436 Args:
437 section: Tuple with section name and optional subsection name
438 name: Variable name
439 Returns:
440 Contents of the setting
441 Raises:
442 KeyError: if the value is not set
443 """
444 raise NotImplementedError(self.get)
446 def get_multivar(self, section: SectionLike, name: NameLike) -> Iterator[Value]:
447 """Retrieve the contents of a multivar configuration setting.
449 Args:
450 section: Tuple with section name and optional subsection namee
451 name: Variable name
452 Returns:
453 Contents of the setting as iterable
454 Raises:
455 KeyError: if the value is not set
456 """
457 raise NotImplementedError(self.get_multivar)
459 @overload
460 def get_boolean(
461 self, section: SectionLike, name: NameLike, default: bool
462 ) -> bool: ...
464 @overload
465 def get_boolean(self, section: SectionLike, name: NameLike) -> bool | None: ...
467 def get_boolean(
468 self, section: SectionLike, name: NameLike, default: bool | None = None
469 ) -> bool | None:
470 """Retrieve a configuration setting as boolean.
472 Args:
473 section: Tuple with section name and optional subsection name
474 name: Name of the setting, including section and possible
475 subsection.
476 default: Default value if setting is not found
478 Returns:
479 Contents of the setting
480 """
481 try:
482 value = self.get(section, name)
483 except KeyError:
484 return default
485 if value.lower() == b"true":
486 return True
487 elif value.lower() == b"false":
488 return False
489 raise ValueError(f"not a valid boolean string: {value!r}")
491 def set(
492 self, section: SectionLike, name: NameLike, value: ValueLike | bool
493 ) -> None:
494 """Set a configuration value.
496 Args:
497 section: Tuple with section name and optional subsection namee
498 name: Name of the configuration value, including section
499 and optional subsection
500 value: value of the setting
501 """
502 raise NotImplementedError(self.set)
504 def items(self, section: SectionLike) -> Iterator[tuple[Name, Value]]:
505 """Iterate over the configuration pairs for a specific section.
507 Args:
508 section: Tuple with section name and optional subsection namee
509 Returns:
510 Iterator over (name, value) pairs
511 """
512 raise NotImplementedError(self.items)
514 def sections(self) -> Iterator[Section]:
515 """Iterate over the sections.
517 Returns: Iterator over section tuples
518 """
519 raise NotImplementedError(self.sections)
521 def has_section(self, name: Section) -> bool:
522 """Check if a specified section exists.
524 Args:
525 name: Name of section to check for
526 Returns:
527 boolean indicating whether the section exists
528 """
529 return name in self.sections()
532class ConfigDict(Config):
533 """Git configuration stored in a dictionary."""
535 def __init__(
536 self,
537 values: MutableMapping[Section, CaseInsensitiveOrderedMultiDict[Name, Value]]
538 | None = None,
539 encoding: str | None = None,
540 ) -> None:
541 """Create a new ConfigDict."""
542 if encoding is None:
543 encoding = sys.getdefaultencoding()
544 self.encoding = encoding
545 self._values: CaseInsensitiveOrderedMultiDict[
546 Section, CaseInsensitiveOrderedMultiDict[Name, Value]
547 ] = CaseInsensitiveOrderedMultiDict.make(
548 values, default_factory=CaseInsensitiveOrderedMultiDict
549 )
551 def __repr__(self) -> str:
552 """Return string representation of ConfigDict."""
553 return f"{self.__class__.__name__}({self._values!r})"
555 def __eq__(self, other: object) -> bool:
556 """Check equality with another ConfigDict."""
557 return isinstance(other, self.__class__) and other._values == self._values
559 def __getitem__(self, key: Section) -> CaseInsensitiveOrderedMultiDict[Name, Value]:
560 """Get configuration values for a section.
562 Raises:
563 KeyError: If section not found
564 """
565 return self._values.__getitem__(key)
567 def __setitem__(
568 self, key: Section, value: CaseInsensitiveOrderedMultiDict[Name, Value]
569 ) -> None:
570 """Set configuration values for a section."""
571 return self._values.__setitem__(key, value)
573 def __delitem__(self, key: Section) -> None:
574 """Delete a configuration section.
576 Raises:
577 KeyError: If section not found
578 """
579 return self._values.__delitem__(key)
581 def __iter__(self) -> Iterator[Section]:
582 """Iterate over configuration sections."""
583 return self._values.__iter__()
585 def __len__(self) -> int:
586 """Return the number of sections."""
587 return self._values.__len__()
589 def keys(self) -> KeysView[Section]:
590 """Return a view of section names."""
591 return self._values.keys()
593 @classmethod
594 def _parse_setting(cls, name: str) -> tuple[str, str | None, str]:
595 parts = name.split(".")
596 if len(parts) == 3:
597 return (parts[0], parts[1], parts[2])
598 else:
599 return (parts[0], None, parts[1])
601 def _check_section_and_name(
602 self, section: SectionLike, name: NameLike
603 ) -> tuple[Section, Name]:
604 if not isinstance(section, tuple):
605 section = (section,)
607 checked_section = tuple(
608 [
609 subsection.encode(self.encoding)
610 if not isinstance(subsection, bytes)
611 else subsection
612 for subsection in section
613 ]
614 )
616 if not isinstance(name, bytes):
617 name = name.encode(self.encoding)
619 return checked_section, name
621 def get_multivar(self, section: SectionLike, name: NameLike) -> Iterator[Value]:
622 """Get multiple values for a configuration setting.
624 Args:
625 section: Section name
626 name: Setting name
628 Returns:
629 Iterator of configuration values
630 """
631 section, name = self._check_section_and_name(section, name)
633 if len(section) > 1:
634 try:
635 return self._values[section].get_all(name)
636 except KeyError:
637 pass
639 return self._values[(section[0],)].get_all(name)
641 def get(
642 self,
643 section: SectionLike,
644 name: NameLike,
645 ) -> Value:
646 """Get a configuration value.
648 Args:
649 section: Section name
650 name: Setting name
652 Returns:
653 Configuration value
655 Raises:
656 KeyError: if the value is not set
657 """
658 section, name = self._check_section_and_name(section, name)
660 if len(section) > 1:
661 try:
662 return self._values[section][name]
663 except KeyError:
664 pass
666 return self._values[(section[0],)][name]
668 def set(
669 self,
670 section: SectionLike,
671 name: NameLike,
672 value: ValueLike | bool,
673 ) -> None:
674 """Set a configuration value.
676 Args:
677 section: Section name
678 name: Setting name
679 value: Configuration value
680 """
681 section, name = self._check_section_and_name(section, name)
683 if isinstance(value, bool):
684 value = b"true" if value else b"false"
686 if not isinstance(value, bytes):
687 value = value.encode(self.encoding)
689 section_dict = self._values.setdefault(section)
690 if hasattr(section_dict, "set"):
691 section_dict.set(name, value)
692 else:
693 section_dict[name] = value
695 def add(
696 self,
697 section: SectionLike,
698 name: NameLike,
699 value: ValueLike | bool,
700 ) -> None:
701 """Add a value to a configuration setting, creating a multivar if needed."""
702 section, name = self._check_section_and_name(section, name)
704 if isinstance(value, bool):
705 value = b"true" if value else b"false"
707 if not isinstance(value, bytes):
708 value = value.encode(self.encoding)
710 self._values.setdefault(section)[name] = value
712 def items(self, section: SectionLike) -> Iterator[tuple[Name, Value]]:
713 """Get items in a section."""
714 section_bytes, _ = self._check_section_and_name(section, b"")
715 section_dict = self._values.get(section_bytes)
716 if section_dict is not None:
717 return iter(section_dict.items())
718 return iter([])
720 def sections(self) -> Iterator[Section]:
721 """Get all sections."""
722 return iter(self._values.keys())
725def _format_string(value: bytes) -> bytes:
726 if (
727 value.startswith((b" ", b"\t"))
728 or value.endswith((b" ", b"\t"))
729 or b"#" in value
730 ):
731 return b'"' + _escape_value(value) + b'"'
732 else:
733 return _escape_value(value)
736_ESCAPE_TABLE = {
737 ord(b"\\"): ord(b"\\"),
738 ord(b'"'): ord(b'"'),
739 ord(b"n"): ord(b"\n"),
740 ord(b"t"): ord(b"\t"),
741 ord(b"b"): ord(b"\b"),
742}
743_COMMENT_CHARS = [ord(b"#"), ord(b";")]
744_WHITESPACE_CHARS = [ord(b"\t"), ord(b" ")]
747def _parse_string(value: bytes) -> bytes:
748 value_array = bytearray(value.strip())
749 ret = bytearray()
750 whitespace = bytearray()
751 in_quotes = False
752 i = 0
753 while i < len(value_array):
754 c = value_array[i]
755 if c == ord(b"\\"):
756 i += 1
757 if i >= len(value_array):
758 # Backslash at end of string - treat as literal backslash
759 if whitespace:
760 ret.extend(whitespace)
761 whitespace = bytearray()
762 ret.append(ord(b"\\"))
763 else:
764 try:
765 v = _ESCAPE_TABLE[value_array[i]]
766 if whitespace:
767 ret.extend(whitespace)
768 whitespace = bytearray()
769 ret.append(v)
770 except KeyError:
771 # Unknown escape sequence - treat backslash as literal and process next char normally
772 if whitespace:
773 ret.extend(whitespace)
774 whitespace = bytearray()
775 ret.append(ord(b"\\"))
776 i -= 1 # Reprocess the character after the backslash
777 elif c == ord(b'"'):
778 in_quotes = not in_quotes
779 elif c in _COMMENT_CHARS and not in_quotes:
780 # the rest of the line is a comment
781 break
782 elif c in _WHITESPACE_CHARS:
783 whitespace.append(c)
784 else:
785 if whitespace:
786 ret.extend(whitespace)
787 whitespace = bytearray()
788 ret.append(c)
789 i += 1
791 if in_quotes:
792 raise ValueError("missing end quote")
794 return bytes(ret)
797def _escape_value(value: bytes) -> bytes:
798 """Escape a value."""
799 value = value.replace(b"\\", b"\\\\")
800 value = value.replace(b"\r", b"\\r")
801 value = value.replace(b"\n", b"\\n")
802 value = value.replace(b"\t", b"\\t")
803 value = value.replace(b'"', b'\\"')
804 return value
807def _check_variable_name(name: bytes) -> bool:
808 for i in range(len(name)):
809 c = name[i : i + 1]
810 if not c.isalnum() and c != b"-":
811 return False
812 return True
815def _check_section_name(name: bytes) -> bool:
816 for i in range(len(name)):
817 c = name[i : i + 1]
818 if not c.isalnum() and c not in (b"-", b"."):
819 return False
820 return True
823def _strip_comments(line: bytes) -> bytes:
824 comment_bytes = {ord(b"#"), ord(b";")}
825 quote = ord(b'"')
826 string_open = False
827 # Normalize line to bytearray for simple 2/3 compatibility
828 for i, character in enumerate(bytearray(line)):
829 # Comment characters outside balanced quotes denote comment start
830 if character == quote:
831 string_open = not string_open
832 elif not string_open and character in comment_bytes:
833 return line[:i]
834 return line
837def _is_line_continuation(value: bytes) -> bool:
838 """Check if a value ends with a line continuation backslash.
840 A line continuation occurs when a line ends with a backslash that is:
841 1. Not escaped (not preceded by another backslash)
842 2. Not within quotes
844 Args:
845 value: The value to check
847 Returns:
848 True if the value ends with a line continuation backslash
849 """
850 if not value.endswith((b"\\\n", b"\\\r\n")):
851 return False
853 # Remove only the newline characters, keep the content including the backslash
854 if value.endswith(b"\\\r\n"):
855 content = value[:-2] # Remove \r\n, keep the \
856 else:
857 content = value[:-1] # Remove \n, keep the \
859 if not content.endswith(b"\\"):
860 return False
862 # Count consecutive backslashes at the end
863 backslash_count = 0
864 for i in range(len(content) - 1, -1, -1):
865 if content[i : i + 1] == b"\\":
866 backslash_count += 1
867 else:
868 break
870 # If we have an odd number of backslashes, the last one is a line continuation
871 # If we have an even number, they are all escaped and there's no continuation
872 return backslash_count % 2 == 1
875def _parse_section_header_line(line: bytes) -> tuple[Section, bytes]:
876 # Parse section header ("[bla]")
877 line = _strip_comments(line).rstrip()
878 in_quotes = False
879 escaped = False
880 for i, c in enumerate(line):
881 if escaped:
882 escaped = False
883 continue
884 if c == ord(b'"'):
885 in_quotes = not in_quotes
886 if c == ord(b"\\"):
887 escaped = True
888 if c == ord(b"]") and not in_quotes:
889 last = i
890 break
891 else:
892 raise ValueError("expected trailing ]")
893 pts = line[1:last].split(b" ", 1)
894 line = line[last + 1 :]
895 section: Section
896 if len(pts) == 2:
897 # Handle subsections - Git allows more complex syntax for certain sections like includeIf
898 if pts[1][:1] == b'"' and pts[1][-1:] == b'"':
899 # Standard quoted subsection
900 pts[1] = pts[1][1:-1]
901 elif pts[0] == b"includeIf":
902 # Special handling for includeIf sections which can have complex conditions
903 # Git allows these without strict quote validation
904 pts[1] = pts[1].strip()
905 if pts[1][:1] == b'"' and pts[1][-1:] == b'"':
906 pts[1] = pts[1][1:-1]
907 else:
908 # Other sections must have quoted subsections
909 raise ValueError(f"Invalid subsection {pts[1]!r}")
910 if not _check_section_name(pts[0]):
911 raise ValueError(f"invalid section name {pts[0]!r}")
912 section = (pts[0], pts[1])
913 else:
914 if not _check_section_name(pts[0]):
915 raise ValueError(f"invalid section name {pts[0]!r}")
916 pts = pts[0].split(b".", 1)
917 if len(pts) == 2:
918 section = (pts[0], pts[1])
919 else:
920 section = (pts[0],)
921 return section, line
924class ConfigFile(ConfigDict):
925 """A Git configuration file, like .git/config or ~/.gitconfig."""
927 def __init__(
928 self,
929 values: MutableMapping[Section, CaseInsensitiveOrderedMultiDict[Name, Value]]
930 | None = None,
931 encoding: str | None = None,
932 ) -> None:
933 """Initialize a ConfigFile.
935 Args:
936 values: Optional mapping of configuration values
937 encoding: Optional encoding for the file (defaults to system encoding)
938 """
939 super().__init__(values=values, encoding=encoding)
940 self.path: str | None = None
941 self._included_paths: set[str] = set() # Track included files to prevent cycles
943 @classmethod
944 def from_file(
945 cls,
946 f: IO[bytes],
947 *,
948 config_dir: str | None = None,
949 included_paths: set[str] | None = None,
950 include_depth: int = 0,
951 max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH,
952 file_opener: FileOpener | None = None,
953 condition_matchers: Mapping[str, ConditionMatcher] | None = None,
954 ) -> "ConfigFile":
955 """Read configuration from a file-like object.
957 Args:
958 f: File-like object to read from
959 config_dir: Directory containing the config file (for relative includes)
960 included_paths: Set of already included paths (to prevent cycles)
961 include_depth: Current include depth (to prevent infinite recursion)
962 max_include_depth: Maximum allowed include depth
963 file_opener: Optional callback to open included files
964 condition_matchers: Optional dict of condition matchers for includeIf
965 """
966 if include_depth > max_include_depth:
967 # Prevent excessive recursion
968 raise ValueError(f"Maximum include depth ({max_include_depth}) exceeded")
970 ret = cls()
971 if included_paths is not None:
972 ret._included_paths = included_paths.copy()
974 section: Section | None = None
975 setting = None
976 continuation = None
977 for lineno, line in enumerate(f.readlines()):
978 if lineno == 0 and line.startswith(b"\xef\xbb\xbf"):
979 line = line[3:]
980 line = line.lstrip()
981 if setting is None:
982 if len(line) > 0 and line[:1] == b"[":
983 section, line = _parse_section_header_line(line)
984 ret._values.setdefault(section)
985 if _strip_comments(line).strip() == b"":
986 continue
987 if section is None:
988 raise ValueError(f"setting {line!r} without section")
989 try:
990 setting, value = line.split(b"=", 1)
991 except ValueError:
992 setting = line
993 value = b"true"
994 setting = setting.strip()
995 if not _check_variable_name(setting):
996 raise ValueError(f"invalid variable name {setting!r}")
997 if _is_line_continuation(value):
998 if value.endswith(b"\\\r\n"):
999 continuation = value[:-3]
1000 else:
1001 continuation = value[:-2]
1002 else:
1003 continuation = None
1004 value = _parse_string(value)
1005 ret._values[section][setting] = value
1007 # Process include/includeIf directives
1008 ret._handle_include_directive(
1009 section,
1010 setting,
1011 value,
1012 config_dir=config_dir,
1013 include_depth=include_depth,
1014 max_include_depth=max_include_depth,
1015 file_opener=file_opener,
1016 condition_matchers=condition_matchers,
1017 )
1019 setting = None
1020 else: # continuation line
1021 assert continuation is not None
1022 if _is_line_continuation(line):
1023 if line.endswith(b"\\\r\n"):
1024 continuation += line[:-3]
1025 else:
1026 continuation += line[:-2]
1027 else:
1028 continuation += line
1029 value = _parse_string(continuation)
1030 assert section is not None # Already checked above
1031 ret._values[section][setting] = value
1033 # Process include/includeIf directives
1034 ret._handle_include_directive(
1035 section,
1036 setting,
1037 value,
1038 config_dir=config_dir,
1039 include_depth=include_depth,
1040 max_include_depth=max_include_depth,
1041 file_opener=file_opener,
1042 condition_matchers=condition_matchers,
1043 )
1045 continuation = None
1046 setting = None
1047 return ret
1049 def _handle_include_directive(
1050 self,
1051 section: Section | None,
1052 setting: bytes,
1053 value: bytes,
1054 *,
1055 config_dir: str | None,
1056 include_depth: int,
1057 max_include_depth: int,
1058 file_opener: FileOpener | None,
1059 condition_matchers: Mapping[str, ConditionMatcher] | None,
1060 ) -> None:
1061 """Handle include/includeIf directives during config parsing."""
1062 if (
1063 section is not None
1064 and setting == b"path"
1065 and (
1066 section[0].lower() == b"include"
1067 or (len(section) > 1 and section[0].lower() == b"includeif")
1068 )
1069 ):
1070 self._process_include(
1071 section,
1072 value,
1073 config_dir=config_dir,
1074 include_depth=include_depth,
1075 max_include_depth=max_include_depth,
1076 file_opener=file_opener,
1077 condition_matchers=condition_matchers,
1078 )
1080 def _process_include(
1081 self,
1082 section: Section,
1083 path_value: bytes,
1084 *,
1085 config_dir: str | None,
1086 include_depth: int,
1087 max_include_depth: int,
1088 file_opener: FileOpener | None,
1089 condition_matchers: Mapping[str, ConditionMatcher] | None,
1090 ) -> None:
1091 """Process an include or includeIf directive."""
1092 path_str = path_value.decode(self.encoding, errors="replace")
1094 # Handle includeIf conditions
1095 if len(section) > 1 and section[0].lower() == b"includeif":
1096 condition = section[1].decode(self.encoding, errors="replace")
1097 if not self._evaluate_includeif_condition(
1098 condition, config_dir, condition_matchers
1099 ):
1100 return
1102 # Resolve the include path
1103 include_path = self._resolve_include_path(path_str, config_dir)
1104 if not include_path:
1105 return
1107 # Check for circular includes
1108 try:
1109 abs_path = str(Path(include_path).resolve())
1110 except (OSError, ValueError) as e:
1111 # Invalid path - log and skip
1112 logger.debug("Invalid include path %r: %s", include_path, e)
1113 return
1114 if abs_path in self._included_paths:
1115 return
1117 # Load and merge the included file
1118 try:
1119 # Use provided file opener or default to GitFile
1120 opener: FileOpener
1121 if file_opener is None:
1123 def opener(path: str | os.PathLike[str]) -> IO[bytes]:
1124 return GitFile(path, "rb")
1125 else:
1126 opener = file_opener
1128 f = opener(include_path)
1129 except (OSError, ValueError) as e:
1130 # Git silently ignores missing or unreadable include files
1131 # Log for debugging purposes
1132 logger.debug("Invalid include path %r: %s", include_path, e)
1133 else:
1134 with f as included_file:
1135 # Track this path to prevent cycles
1136 self._included_paths.add(abs_path)
1138 # Parse the included file
1139 included_config = ConfigFile.from_file(
1140 included_file,
1141 config_dir=os.path.dirname(include_path),
1142 included_paths=self._included_paths,
1143 include_depth=include_depth + 1,
1144 max_include_depth=max_include_depth,
1145 file_opener=file_opener,
1146 condition_matchers=condition_matchers,
1147 )
1149 # Merge the included configuration
1150 self._merge_config(included_config)
1152 def _merge_config(self, other: "ConfigFile") -> None:
1153 """Merge another config file into this one."""
1154 for section, values in other._values.items():
1155 if section not in self._values:
1156 self._values[section] = CaseInsensitiveOrderedMultiDict()
1157 for key, value in values.items():
1158 self._values[section][key] = value
1160 def _resolve_include_path(self, path: str, config_dir: str | None) -> str | None:
1161 """Resolve an include path to an absolute path."""
1162 # Expand ~ to home directory
1163 path = os.path.expanduser(path)
1165 # If path is relative and we have a config directory, make it relative to that
1166 if not os.path.isabs(path) and config_dir:
1167 path = os.path.join(config_dir, path)
1169 return path
1171 def _evaluate_includeif_condition(
1172 self,
1173 condition: str,
1174 config_dir: str | None = None,
1175 condition_matchers: Mapping[str, ConditionMatcher] | None = None,
1176 ) -> bool:
1177 """Evaluate an includeIf condition."""
1178 # Try custom matchers first if provided
1179 if condition_matchers:
1180 for prefix, matcher in condition_matchers.items():
1181 if condition.startswith(prefix):
1182 return matcher(condition[len(prefix) :])
1184 # Fall back to built-in matchers
1185 if condition.startswith("hasconfig:"):
1186 return self._evaluate_hasconfig_condition(condition[10:])
1187 else:
1188 # Unknown condition type - log and ignore (Git behavior)
1189 logger.debug("Unknown includeIf condition: %r", condition)
1190 return False
1192 def _evaluate_hasconfig_condition(self, condition: str) -> bool:
1193 """Evaluate a hasconfig condition.
1195 Format: hasconfig:config.key:pattern
1196 Example: hasconfig:remote.*.url:ssh://org-*@github.com/**
1197 """
1198 # Split on the first colon to separate config key from pattern
1199 parts = condition.split(":", 1)
1200 if len(parts) != 2:
1201 logger.debug("Invalid hasconfig condition format: %r", condition)
1202 return False
1204 config_key, pattern = parts
1206 # Parse the config key to get section and name
1207 key_parts = config_key.split(".", 2)
1208 if len(key_parts) < 2:
1209 logger.debug("Invalid hasconfig config key: %r", config_key)
1210 return False
1212 # Handle wildcards in section names (e.g., remote.*)
1213 if len(key_parts) == 3 and key_parts[1] == "*":
1214 # Match any subsection
1215 section_prefix = key_parts[0].encode(self.encoding)
1216 name = key_parts[2].encode(self.encoding)
1218 # Check all sections that match the pattern
1219 for section in self.sections():
1220 if len(section) == 2 and section[0] == section_prefix:
1221 try:
1222 values = list(self.get_multivar(section, name))
1223 for value in values:
1224 if self._match_hasconfig_pattern(value, pattern):
1225 return True
1226 except KeyError:
1227 continue
1228 else:
1229 # Direct section lookup
1230 if len(key_parts) == 2:
1231 section = (key_parts[0].encode(self.encoding),)
1232 name = key_parts[1].encode(self.encoding)
1233 else:
1234 section = (
1235 key_parts[0].encode(self.encoding),
1236 key_parts[1].encode(self.encoding),
1237 )
1238 name = key_parts[2].encode(self.encoding)
1240 try:
1241 values = list(self.get_multivar(section, name))
1242 for value in values:
1243 if self._match_hasconfig_pattern(value, pattern):
1244 return True
1245 except KeyError:
1246 pass
1248 return False
1250 def _match_hasconfig_pattern(self, value: bytes, pattern: str) -> bool:
1251 """Match a config value against a hasconfig pattern.
1253 Supports simple glob patterns like ``*`` and ``**``.
1254 """
1255 value_str = value.decode(self.encoding, errors="replace")
1256 return match_glob_pattern(value_str, pattern)
1258 @classmethod
1259 def from_path(
1260 cls,
1261 path: str | os.PathLike[str],
1262 *,
1263 max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH,
1264 file_opener: FileOpener | None = None,
1265 condition_matchers: Mapping[str, ConditionMatcher] | None = None,
1266 ) -> "ConfigFile":
1267 """Read configuration from a file on disk.
1269 Args:
1270 path: Path to the configuration file
1271 max_include_depth: Maximum allowed include depth
1272 file_opener: Optional callback to open included files
1273 condition_matchers: Optional dict of condition matchers for includeIf
1274 """
1275 abs_path = os.fspath(path)
1276 config_dir = os.path.dirname(abs_path)
1278 # Use provided file opener or default to GitFile
1279 opener: FileOpener
1280 if file_opener is None:
1282 def opener(p: str | os.PathLike[str]) -> IO[bytes]:
1283 return GitFile(p, "rb")
1284 else:
1285 opener = file_opener
1287 with opener(abs_path) as f:
1288 ret = cls.from_file(
1289 f,
1290 config_dir=config_dir,
1291 max_include_depth=max_include_depth,
1292 file_opener=file_opener,
1293 condition_matchers=condition_matchers,
1294 )
1295 ret.path = abs_path
1296 return ret
1298 def write_to_path(self, path: str | os.PathLike[str] | None = None) -> None:
1299 """Write configuration to a file on disk."""
1300 if path is None:
1301 if self.path is None:
1302 raise ValueError("No path specified and no default path available")
1303 path_to_use: str | os.PathLike[str] = self.path
1304 else:
1305 path_to_use = path
1306 with GitFile(path_to_use, "wb") as f:
1307 self.write_to_file(f)
1309 def write_to_file(self, f: IO[bytes] | _GitFile) -> None:
1310 """Write configuration to a file-like object."""
1311 for section, values in self._values.items():
1312 try:
1313 section_name, subsection_name = section
1314 except ValueError:
1315 (section_name,) = section
1316 subsection_name = None
1317 if subsection_name is None:
1318 f.write(b"[" + section_name + b"]\n")
1319 else:
1320 f.write(b"[" + section_name + b' "' + subsection_name + b'"]\n')
1321 for key, value in values.items():
1322 value = _format_string(value)
1323 f.write(b"\t" + key + b" = " + value + b"\n")
1326def get_xdg_config_home_path(*path_segments: str) -> str:
1327 """Get a path in the XDG config home directory.
1329 Args:
1330 *path_segments: Path segments to join to the XDG config home
1332 Returns:
1333 Full path in XDG config home directory
1334 """
1335 xdg_config_home = os.environ.get(
1336 "XDG_CONFIG_HOME",
1337 os.path.expanduser("~/.config/"),
1338 )
1339 return os.path.join(xdg_config_home, *path_segments)
1342def _find_git_in_win_path() -> Iterator[str]:
1343 for exe in ("git.exe", "git.cmd"):
1344 for path in os.environ.get("PATH", "").split(";"):
1345 if os.path.exists(os.path.join(path, exe)):
1346 # in windows native shells (powershell/cmd) exe path is
1347 # .../Git/bin/git.exe or .../Git/cmd/git.exe
1348 #
1349 # in git-bash exe path is .../Git/mingw64/bin/git.exe
1350 git_dir, _bin_dir = os.path.split(path)
1351 yield git_dir
1352 parent_dir, basename = os.path.split(git_dir)
1353 if basename == "mingw32" or basename == "mingw64":
1354 yield parent_dir
1355 break
1358def _find_git_in_win_reg() -> Iterator[str]:
1359 import platform
1360 import winreg
1362 if platform.machine() == "AMD64":
1363 subkey = (
1364 "SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\"
1365 "CurrentVersion\\Uninstall\\Git_is1"
1366 )
1367 else:
1368 subkey = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Git_is1"
1370 for key in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE): # type: ignore[attr-defined,unused-ignore]
1371 with suppress(OSError):
1372 with winreg.OpenKey(key, subkey) as k: # type: ignore[attr-defined,unused-ignore]
1373 val, typ = winreg.QueryValueEx(k, "InstallLocation") # type: ignore[attr-defined,unused-ignore]
1374 if typ == winreg.REG_SZ: # type: ignore[attr-defined,unused-ignore]
1375 yield val
1378# There is no set standard for system config dirs on windows. We try the
1379# following:
1380# - %PROGRAMFILES%/Git/etc/gitconfig - Git for Windows (msysgit) config dir
1381# Used if CGit installation (Git/bin/git.exe) is found in PATH in the
1382# system registry
1383def get_win_system_paths() -> Iterator[str]:
1384 """Get current Windows system Git config paths.
1386 Only returns the current Git for Windows config location, not legacy paths.
1387 """
1388 # Try to find Git installation from PATH first
1389 for git_dir in _find_git_in_win_path():
1390 yield os.path.join(git_dir, "etc", "gitconfig")
1391 return # Only use the first found path
1393 # Fall back to registry if not found in PATH
1394 for git_dir in _find_git_in_win_reg():
1395 yield os.path.join(git_dir, "etc", "gitconfig")
1396 return # Only use the first found path
1399def get_win_legacy_system_paths() -> Iterator[str]:
1400 """Get legacy Windows system Git config paths.
1402 Returns all possible config paths including deprecated locations.
1403 This function can be used for diagnostics or migration purposes.
1404 """
1405 # Include deprecated PROGRAMDATA location
1406 if "PROGRAMDATA" in os.environ:
1407 yield os.path.join(os.environ["PROGRAMDATA"], "Git", "config")
1409 # Include all Git installations found
1410 for git_dir in _find_git_in_win_path():
1411 yield os.path.join(git_dir, "etc", "gitconfig")
1412 for git_dir in _find_git_in_win_reg():
1413 yield os.path.join(git_dir, "etc", "gitconfig")
1416class StackedConfig(Config):
1417 """Configuration which reads from multiple config files.."""
1419 def __init__(
1420 self, backends: list[ConfigFile], writable: ConfigFile | None = None
1421 ) -> None:
1422 """Initialize a StackedConfig.
1424 Args:
1425 backends: List of config files to read from (in order of precedence)
1426 writable: Optional config file to write changes to
1427 """
1428 self.backends = backends
1429 self.writable = writable
1431 def __repr__(self) -> str:
1432 """Return string representation of StackedConfig."""
1433 return f"<{self.__class__.__name__} for {self.backends!r}>"
1435 @classmethod
1436 def default(cls) -> "StackedConfig":
1437 """Create a StackedConfig with default system/user config files.
1439 Returns:
1440 StackedConfig with default configuration files loaded
1441 """
1442 return cls(cls.default_backends())
1444 @classmethod
1445 def default_backends(cls) -> list[ConfigFile]:
1446 """Retrieve the default configuration.
1448 See git-config(1) for details on the files searched.
1449 """
1450 paths = []
1452 # Handle GIT_CONFIG_GLOBAL - overrides user config paths
1453 try:
1454 paths.append(os.environ["GIT_CONFIG_GLOBAL"])
1455 except KeyError:
1456 paths.append(os.path.expanduser("~/.gitconfig"))
1457 paths.append(get_xdg_config_home_path("git", "config"))
1459 # Handle GIT_CONFIG_SYSTEM and GIT_CONFIG_NOSYSTEM
1460 try:
1461 paths.append(os.environ["GIT_CONFIG_SYSTEM"])
1462 except KeyError:
1463 if "GIT_CONFIG_NOSYSTEM" not in os.environ:
1464 paths.append("/etc/gitconfig")
1465 if sys.platform == "win32":
1466 paths.extend(get_win_system_paths())
1468 logger.debug("Loading gitconfig from paths: %s", paths)
1470 backends = []
1471 for path in paths:
1472 try:
1473 cf = ConfigFile.from_path(path)
1474 logger.debug("Successfully loaded gitconfig from: %s", path)
1475 except FileNotFoundError:
1476 logger.debug("Gitconfig file not found: %s", path)
1477 continue
1478 backends.append(cf)
1479 return backends
1481 def get(self, section: SectionLike, name: NameLike) -> Value:
1482 """Get value from configuration."""
1483 if not isinstance(section, tuple):
1484 section = (section,)
1485 for backend in self.backends:
1486 try:
1487 return backend.get(section, name)
1488 except KeyError:
1489 pass
1490 raise KeyError(name)
1492 def get_multivar(self, section: SectionLike, name: NameLike) -> Iterator[Value]:
1493 """Get multiple values from configuration."""
1494 if not isinstance(section, tuple):
1495 section = (section,)
1496 for backend in self.backends:
1497 try:
1498 yield from backend.get_multivar(section, name)
1499 except KeyError:
1500 pass
1502 def set(
1503 self, section: SectionLike, name: NameLike, value: ValueLike | bool
1504 ) -> None:
1505 """Set value in configuration."""
1506 if self.writable is None:
1507 raise NotImplementedError(self.set)
1508 return self.writable.set(section, name, value)
1510 def sections(self) -> Iterator[Section]:
1511 """Get all sections."""
1512 seen = set()
1513 for backend in self.backends:
1514 for section in backend.sections():
1515 if section not in seen:
1516 seen.add(section)
1517 yield section
1520def read_submodules(
1521 path: str | os.PathLike[str],
1522) -> Iterator[tuple[bytes, bytes, bytes]]:
1523 """Read a .gitmodules file."""
1524 cfg = ConfigFile.from_path(path)
1525 return parse_submodules(cfg)
1528def parse_submodules(config: ConfigFile) -> Iterator[tuple[bytes, bytes, bytes]]:
1529 """Parse a gitmodules GitConfig file, returning submodules.
1531 Args:
1532 config: A `ConfigFile`
1533 Returns:
1534 list of tuples (submodule path, url, name),
1535 where name is quoted part of the section's name.
1536 """
1537 for section in config.sections():
1538 section_kind, section_name = section
1539 if section_kind == b"submodule":
1540 try:
1541 sm_path = config.get(section, b"path")
1542 sm_url = config.get(section, b"url")
1543 yield (sm_path, sm_url, section_name)
1544 except KeyError:
1545 # If either path or url is missing, just ignore this
1546 # submodule entry and move on to the next one. This is
1547 # how git itself handles malformed .gitmodule entries.
1548 pass
1551def iter_instead_of(config: Config, push: bool = False) -> Iterable[tuple[str, str]]:
1552 """Iterate over insteadOf / pushInsteadOf values."""
1553 for section in config.sections():
1554 if section[0] != b"url":
1555 continue
1556 replacement = section[1]
1557 try:
1558 needles = list(config.get_multivar(section, "insteadOf"))
1559 except KeyError:
1560 needles = []
1561 if push:
1562 try:
1563 needles += list(config.get_multivar(section, "pushInsteadOf"))
1564 except KeyError:
1565 pass
1566 for needle in needles:
1567 assert isinstance(needle, bytes)
1568 yield needle.decode("utf-8"), replacement.decode("utf-8")
1571def apply_instead_of(config: Config, orig_url: str, push: bool = False) -> str:
1572 """Apply insteadOf / pushInsteadOf to a URL."""
1573 longest_needle = ""
1574 updated_url = orig_url
1575 for needle, replacement in iter_instead_of(config, push):
1576 if not orig_url.startswith(needle):
1577 continue
1578 if len(longest_needle) < len(needle):
1579 longest_needle = needle
1580 updated_url = replacement + orig_url[len(needle) :]
1581 return updated_url