config.py 42 KB

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