config.py 53 KB

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