Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/dulwich/config.py: 57%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

800 statements  

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 

22"""Reading and writing Git configuration files. 

23 

24Todo: 

25 * preserve formatting when updating configuration files 

26""" 

27 

28__all__ = [ 

29 "DEFAULT_MAX_INCLUDE_DEPTH", 

30 "MAX_INCLUDE_FILE_SIZE", 

31 "CaseInsensitiveOrderedMultiDict", 

32 "ConditionMatcher", 

33 "Config", 

34 "ConfigDict", 

35 "ConfigFile", 

36 "ConfigKey", 

37 "ConfigValue", 

38 "FileOpener", 

39 "StackedConfig", 

40 "apply_instead_of", 

41 "env_config", 

42 "get_win_legacy_system_paths", 

43 "get_win_system_paths", 

44 "get_xdg_config_home_path", 

45 "iter_instead_of", 

46 "lower_key", 

47 "match_glob_pattern", 

48 "parse_submodules", 

49 "read_submodules", 

50] 

51 

52import logging 

53import os 

54import re 

55import sys 

56from collections.abc import ( 

57 Callable, 

58 ItemsView, 

59 Iterable, 

60 Iterator, 

61 KeysView, 

62 Mapping, 

63 MutableMapping, 

64 ValuesView, 

65) 

66from contextlib import suppress 

67from pathlib import Path 

68from typing import ( 

69 IO, 

70 Generic, 

71 TypeVar, 

72 overload, 

73) 

74 

75from .file import GitFile, _GitFile 

76 

77ConfigKey = str | bytes | tuple[str | bytes, ...] 

78ConfigValue = str | bytes | bool | int 

79 

80logger = logging.getLogger(__name__) 

81 

82# Type for file opener callback 

83FileOpener = Callable[[str | os.PathLike[str]], IO[bytes]] 

84 

85# Type for includeIf condition matcher 

86# Takes the condition value (e.g., "main" for onbranch:main) and returns bool 

87ConditionMatcher = Callable[[str], bool] 

88 

89# Security limits for include files 

90MAX_INCLUDE_FILE_SIZE = 1024 * 1024 # 1MB max for included config files 

91DEFAULT_MAX_INCLUDE_DEPTH = 10 # Maximum recursion depth for includes 

92 

93 

94def _match_gitdir_pattern( 

95 path: bytes, pattern: bytes, ignorecase: bool = False 

96) -> bool: 

97 """Simple gitdir pattern matching for includeIf conditions. 

98 

99 This handles the basic gitdir patterns used in includeIf directives. 

100 """ 

101 # Convert to strings for easier manipulation 

102 path_str = path.decode("utf-8", errors="replace") 

103 pattern_str = pattern.decode("utf-8", errors="replace") 

104 

105 # Normalize paths to use forward slashes for consistent matching 

106 path_str = path_str.replace("\\", "/") 

107 pattern_str = pattern_str.replace("\\", "/") 

108 

109 if ignorecase: 

110 path_str = path_str.lower() 

111 pattern_str = pattern_str.lower() 

112 

113 # Handle the common cases for gitdir patterns 

114 if pattern_str.startswith("**/") and pattern_str.endswith("/**"): 

115 # Pattern like **/dirname/** should match any path containing dirname 

116 dirname = pattern_str[3:-3] # Remove **/ and /** 

117 # Check if path contains the directory name as a path component 

118 return ("/" + dirname + "/") in path_str or path_str.endswith("/" + dirname) 

119 elif pattern_str.startswith("**/"): 

120 # Pattern like **/filename 

121 suffix = pattern_str[3:] # Remove **/ 

122 return suffix in path_str or path_str.endswith("/" + suffix) 

123 elif pattern_str.endswith("/**"): 

124 # Pattern like /path/to/dir/** should match /path/to/dir and any subdirectory 

125 base_pattern = pattern_str[:-3] # Remove /** 

126 return path_str == base_pattern or path_str.startswith(base_pattern + "/") 

127 elif "**" in pattern_str: 

128 # Handle patterns with ** in the middle 

129 parts = pattern_str.split("**") 

130 if len(parts) == 2: 

131 prefix, suffix = parts 

132 # Path must start with prefix and end with suffix (if any) 

133 if prefix and not path_str.startswith(prefix): 

134 return False 

135 if suffix and not path_str.endswith(suffix): 

136 return False 

137 return True 

138 

139 # Direct match or simple glob pattern 

140 if "*" in pattern_str or "?" in pattern_str or "[" in pattern_str: 

141 import fnmatch 

142 

143 return fnmatch.fnmatch(path_str, pattern_str) 

144 else: 

145 return path_str == pattern_str 

146 

147 

148def match_glob_pattern(value: str, pattern: str) -> bool: 

149 r"""Match a value against a glob pattern. 

150 

151 Supports simple glob patterns like ``*`` and ``**``. 

152 

153 Raises: 

154 ValueError: If the pattern is invalid 

155 """ 

156 # Convert glob pattern to regex 

157 pattern_escaped = re.escape(pattern) 

158 # Replace escaped \*\* with .* (match anything) 

159 pattern_escaped = pattern_escaped.replace(r"\*\*", ".*") 

160 # Replace escaped \* with [^/]* (match anything except /) 

161 pattern_escaped = pattern_escaped.replace(r"\*", "[^/]*") 

162 # Anchor the pattern 

163 pattern_regex = f"^{pattern_escaped}$" 

164 

165 try: 

166 return bool(re.match(pattern_regex, value)) 

167 except re.error as e: 

168 raise ValueError(f"Invalid glob pattern {pattern!r}: {e}") 

169 

170 

171def lower_key(key: ConfigKey) -> ConfigKey: 

172 """Convert a config key to lowercase, preserving subsection case. 

173 

174 Args: 

175 key: Configuration key (str, bytes, or tuple) 

176 

177 Returns: 

178 Key with section names lowercased, subsection names preserved 

179 

180 Raises: 

181 TypeError: If key is not str, bytes, or tuple 

182 """ 

183 if isinstance(key, bytes | str): 

184 return key.lower() 

185 

186 if isinstance(key, tuple): 

187 # For config sections, only lowercase the section name (first element) 

188 # but preserve the case of subsection names (remaining elements) 

189 if len(key) > 0: 

190 first = key[0] 

191 assert isinstance(first, bytes | str) 

192 return (first.lower(), *key[1:]) 

193 return key 

194 

195 raise TypeError(key) 

196 

197 

198K = TypeVar("K", bound=ConfigKey) # Key type must be ConfigKey 

199V = TypeVar("V") # Value type 

200_T = TypeVar("_T") # For get() default parameter 

201 

202 

203class CaseInsensitiveOrderedMultiDict(MutableMapping[K, V], Generic[K, V]): 

204 """A case-insensitive ordered dictionary that can store multiple values per key. 

205 

206 This class maintains the order of insertions and allows multiple values 

207 for the same key. Keys are compared case-insensitively. 

208 """ 

209 

210 def __init__(self, default_factory: Callable[[], V] | None = None) -> None: 

211 """Initialize a CaseInsensitiveOrderedMultiDict. 

212 

213 Args: 

214 default_factory: Optional factory function for default values 

215 """ 

216 self._real: list[tuple[K, V]] = [] 

217 self._keyed: dict[ConfigKey, V] = {} 

218 self._default_factory = default_factory 

219 

220 @classmethod 

221 def make( 

222 cls, 

223 dict_in: "MutableMapping[K, V] | CaseInsensitiveOrderedMultiDict[K, V] | None" = None, 

224 default_factory: Callable[[], V] | None = None, 

225 ) -> "CaseInsensitiveOrderedMultiDict[K, V]": 

226 """Create a CaseInsensitiveOrderedMultiDict from an existing mapping. 

227 

228 Args: 

229 dict_in: Optional mapping to initialize from 

230 default_factory: Optional factory function for default values 

231 

232 Returns: 

233 New CaseInsensitiveOrderedMultiDict instance 

234 

235 Raises: 

236 TypeError: If dict_in is not a mapping or None 

237 """ 

238 if isinstance(dict_in, cls): 

239 return dict_in 

240 

241 out = cls(default_factory=default_factory) 

242 

243 if dict_in is None: 

244 return out 

245 

246 if not isinstance(dict_in, MutableMapping): 

247 raise TypeError 

248 

249 for key, value in dict_in.items(): 

250 out[key] = value 

251 

252 return out 

253 

254 def __len__(self) -> int: 

255 """Return the number of unique keys in the dictionary.""" 

256 return len(self._keyed) 

257 

258 def keys(self) -> KeysView[K]: 

259 """Return a view of the dictionary's keys.""" 

260 # Return a view of the original keys (not lowercased) 

261 # We need to deduplicate since _real can have duplicates 

262 seen = set() 

263 unique_keys = [] 

264 for k, _ in self._real: 

265 lower = lower_key(k) 

266 if lower not in seen: 

267 seen.add(lower) 

268 unique_keys.append(k) 

269 from collections.abc import KeysView as ABCKeysView 

270 

271 class UniqueKeysView(ABCKeysView[K]): 

272 def __init__(self, keys: list[K]): 

273 self._keys = keys 

274 

275 def __contains__(self, key: object) -> bool: 

276 return key in self._keys 

277 

278 def __iter__(self) -> Iterator[K]: 

279 return iter(self._keys) 

280 

281 def __len__(self) -> int: 

282 return len(self._keys) 

283 

284 return UniqueKeysView(unique_keys) 

285 

286 def items(self) -> ItemsView[K, V]: 

287 """Return a view of the dictionary's (key, value) pairs in insertion order.""" 

288 

289 # Return a view that iterates over the real list to preserve order 

290 class OrderedItemsView(ItemsView[K, V]): 

291 """Items view that preserves insertion order.""" 

292 

293 def __init__(self, mapping: CaseInsensitiveOrderedMultiDict[K, V]): 

294 self._mapping = mapping 

295 

296 def __iter__(self) -> Iterator[tuple[K, V]]: 

297 return iter(self._mapping._real) 

298 

299 def __len__(self) -> int: 

300 return len(self._mapping._real) 

301 

302 def __contains__(self, item: object) -> bool: 

303 if not isinstance(item, tuple) or len(item) != 2: 

304 return False 

305 key, value = item 

306 return any(k == key and v == value for k, v in self._mapping._real) 

307 

308 return OrderedItemsView(self) 

309 

310 def __iter__(self) -> Iterator[K]: 

311 """Iterate over the dictionary's keys.""" 

312 # Return iterator over original keys (not lowercased), deduplicated 

313 seen = set() 

314 for k, _ in self._real: 

315 lower = lower_key(k) 

316 if lower not in seen: 

317 seen.add(lower) 

318 yield k 

319 

320 def values(self) -> ValuesView[V]: 

321 """Return a view of the dictionary's values.""" 

322 return self._keyed.values() 

323 

324 def __setitem__(self, key: K, value: V) -> None: 

325 """Set a value for a key, appending to existing values.""" 

326 self._real.append((key, value)) 

327 self._keyed[lower_key(key)] = value 

328 

329 def set(self, key: K, value: V) -> None: 

330 """Set a value for a key, replacing all existing values. 

331 

332 Args: 

333 key: The key to set 

334 value: The value to set 

335 """ 

336 # This method replaces all existing values for the key 

337 lower = lower_key(key) 

338 self._real = [(k, v) for k, v in self._real if lower_key(k) != lower] 

339 self._real.append((key, value)) 

340 self._keyed[lower] = value 

341 

342 def __delitem__(self, key: K) -> None: 

343 """Delete all values for a key. 

344 

345 Raises: 

346 KeyError: If the key is not found 

347 """ 

348 lower_k = lower_key(key) 

349 del self._keyed[lower_k] 

350 for i, (actual, unused_value) in reversed(list(enumerate(self._real))): 

351 if lower_key(actual) == lower_k: 

352 del self._real[i] 

353 

354 def __getitem__(self, item: K) -> V: 

355 """Get the last value for a key. 

356 

357 Raises: 

358 KeyError: If the key is not found 

359 """ 

360 return self._keyed[lower_key(item)] 

361 

362 def get(self, key: K, /, default: V | _T | None = None) -> V | _T | None: # type: ignore[override] 

363 """Get the last value for a key, or a default if not found. 

364 

365 Args: 

366 key: The key to look up 

367 default: Default value to return if key not found 

368 

369 Returns: 

370 The value for the key, or default/default_factory result if not found 

371 """ 

372 try: 

373 return self[key] 

374 except KeyError: 

375 if default is not None: 

376 return default 

377 elif self._default_factory is not None: 

378 return self._default_factory() 

379 else: 

380 return None 

381 

382 def get_all(self, key: K) -> Iterator[V]: 

383 """Get all values for a key in insertion order. 

384 

385 Args: 

386 key: The key to look up 

387 

388 Returns: 

389 Iterator of all values for the key 

390 """ 

391 lowered_key = lower_key(key) 

392 for actual, value in self._real: 

393 if lower_key(actual) == lowered_key: 

394 yield value 

395 

396 def setdefault(self, key: K, default: V | None = None) -> V: 

397 """Get value for key, setting it to default if not present. 

398 

399 Args: 

400 key: The key to look up 

401 default: Default value to set if key not found 

402 

403 Returns: 

404 The existing value or the newly set default 

405 

406 Raises: 

407 KeyError: If key not found and no default or default_factory 

408 """ 

409 try: 

410 return self[key] 

411 except KeyError: 

412 if default is not None: 

413 self[key] = default 

414 return default 

415 elif self._default_factory is not None: 

416 value = self._default_factory() 

417 self[key] = value 

418 return value 

419 else: 

420 raise 

421 

422 

423Name = bytes 

424NameLike = bytes | str 

425Section = tuple[bytes, ...] 

426SectionLike = bytes | str | tuple[bytes | str, ...] 

427Value = bytes 

428ValueLike = bytes | str 

429 

430 

431class Config: 

432 """A Git configuration.""" 

433 

434 def get(self, section: SectionLike, name: NameLike) -> Value: 

435 """Retrieve the contents of a configuration setting. 

436 

437 Args: 

438 section: Tuple with section name and optional subsection name 

439 name: Variable name 

440 Returns: 

441 Contents of the setting 

442 Raises: 

443 KeyError: if the value is not set 

444 """ 

445 raise NotImplementedError(self.get) 

446 

447 def get_multivar(self, section: SectionLike, name: NameLike) -> Iterator[Value]: 

448 """Retrieve the contents of a multivar configuration setting. 

449 

450 Args: 

451 section: Tuple with section name and optional subsection namee 

452 name: Variable name 

453 Returns: 

454 Contents of the setting as iterable 

455 Raises: 

456 KeyError: if the value is not set 

457 """ 

458 raise NotImplementedError(self.get_multivar) 

459 

460 @overload 

461 def get_boolean( 

462 self, section: SectionLike, name: NameLike, default: bool 

463 ) -> bool: ... 

464 

465 @overload 

466 def get_boolean(self, section: SectionLike, name: NameLike) -> bool | None: ... 

467 

468 def get_boolean( 

469 self, section: SectionLike, name: NameLike, default: bool | None = None 

470 ) -> bool | None: 

471 """Retrieve a configuration setting as boolean. 

472 

473 Args: 

474 section: Tuple with section name and optional subsection name 

475 name: Name of the setting, including section and possible 

476 subsection. 

477 default: Default value if setting is not found 

478 

479 Returns: 

480 Contents of the setting 

481 """ 

482 try: 

483 value = self.get(section, name) 

484 except KeyError: 

485 return default 

486 if value.lower() == b"true": 

487 return True 

488 elif value.lower() == b"false": 

489 return False 

490 raise ValueError(f"not a valid boolean string: {value!r}") 

491 

492 def set( 

493 self, section: SectionLike, name: NameLike, value: ValueLike | bool 

494 ) -> None: 

495 """Set a configuration value. 

496 

497 Args: 

498 section: Tuple with section name and optional subsection namee 

499 name: Name of the configuration value, including section 

500 and optional subsection 

501 value: value of the setting 

502 """ 

503 raise NotImplementedError(self.set) 

504 

505 def items(self, section: SectionLike) -> Iterator[tuple[Name, Value]]: 

506 """Iterate over the configuration pairs for a specific section. 

507 

508 Args: 

509 section: Tuple with section name and optional subsection namee 

510 Returns: 

511 Iterator over (name, value) pairs 

512 """ 

513 raise NotImplementedError(self.items) 

514 

515 def sections(self) -> Iterator[Section]: 

516 """Iterate over the sections. 

517 

518 Returns: Iterator over section tuples 

519 """ 

520 raise NotImplementedError(self.sections) 

521 

522 def has_section(self, name: Section) -> bool: 

523 """Check if a specified section exists. 

524 

525 Args: 

526 name: Name of section to check for 

527 Returns: 

528 boolean indicating whether the section exists 

529 """ 

530 return name in self.sections() 

531 

532 

533class ConfigDict(Config): 

534 """Git configuration stored in a dictionary.""" 

535 

536 def __init__( 

537 self, 

538 values: MutableMapping[Section, CaseInsensitiveOrderedMultiDict[Name, Value]] 

539 | None = None, 

540 encoding: str | None = None, 

541 ) -> None: 

542 """Create a new ConfigDict.""" 

543 if encoding is None: 

544 encoding = sys.getdefaultencoding() 

545 self.encoding = encoding 

546 self._values: CaseInsensitiveOrderedMultiDict[ 

547 Section, CaseInsensitiveOrderedMultiDict[Name, Value] 

548 ] = CaseInsensitiveOrderedMultiDict.make( 

549 values, default_factory=CaseInsensitiveOrderedMultiDict 

550 ) 

551 

552 def __repr__(self) -> str: 

553 """Return string representation of ConfigDict.""" 

554 return f"{self.__class__.__name__}({self._values!r})" 

555 

556 def __eq__(self, other: object) -> bool: 

557 """Check equality with another ConfigDict.""" 

558 return isinstance(other, self.__class__) and other._values == self._values 

559 

560 def __getitem__(self, key: Section) -> CaseInsensitiveOrderedMultiDict[Name, Value]: 

561 """Get configuration values for a section. 

562 

563 Raises: 

564 KeyError: If section not found 

565 """ 

566 return self._values.__getitem__(key) 

567 

568 def __setitem__( 

569 self, key: Section, value: CaseInsensitiveOrderedMultiDict[Name, Value] 

570 ) -> None: 

571 """Set configuration values for a section.""" 

572 return self._values.__setitem__(key, value) 

573 

574 def __delitem__(self, key: Section) -> None: 

575 """Delete a configuration section. 

576 

577 Raises: 

578 KeyError: If section not found 

579 """ 

580 return self._values.__delitem__(key) 

581 

582 def __iter__(self) -> Iterator[Section]: 

583 """Iterate over configuration sections.""" 

584 return self._values.__iter__() 

585 

586 def __len__(self) -> int: 

587 """Return the number of sections.""" 

588 return self._values.__len__() 

589 

590 def keys(self) -> KeysView[Section]: 

591 """Return a view of section names.""" 

592 return self._values.keys() 

593 

594 @classmethod 

595 def _parse_setting(cls, name: str) -> tuple[str, str | None, str]: 

596 parts = name.split(".") 

597 if len(parts) == 3: 

598 return (parts[0], parts[1], parts[2]) 

599 else: 

600 return (parts[0], None, parts[1]) 

601 

602 def _check_section_and_name( 

603 self, section: SectionLike, name: NameLike 

604 ) -> tuple[Section, Name]: 

605 if not isinstance(section, tuple): 

606 section = (section,) 

607 

608 checked_section = tuple( 

609 [ 

610 subsection.encode(self.encoding) 

611 if not isinstance(subsection, bytes) 

612 else subsection 

613 for subsection in section 

614 ] 

615 ) 

616 

617 if not isinstance(name, bytes): 

618 name = name.encode(self.encoding) 

619 

620 return checked_section, name 

621 

622 def get_multivar(self, section: SectionLike, name: NameLike) -> Iterator[Value]: 

623 """Get multiple values for a configuration setting. 

624 

625 Args: 

626 section: Section name 

627 name: Setting name 

628 

629 Returns: 

630 Iterator of configuration values 

631 """ 

632 section, name = self._check_section_and_name(section, name) 

633 

634 if len(section) > 1: 

635 try: 

636 return self._values[section].get_all(name) 

637 except KeyError: 

638 pass 

639 

640 return self._values[(section[0],)].get_all(name) 

641 

642 def get( 

643 self, 

644 section: SectionLike, 

645 name: NameLike, 

646 ) -> Value: 

647 """Get a configuration value. 

648 

649 Args: 

650 section: Section name 

651 name: Setting name 

652 

653 Returns: 

654 Configuration value 

655 

656 Raises: 

657 KeyError: if the value is not set 

658 """ 

659 section, name = self._check_section_and_name(section, name) 

660 

661 if len(section) > 1: 

662 try: 

663 return self._values[section][name] 

664 except KeyError: 

665 pass 

666 

667 return self._values[(section[0],)][name] 

668 

669 def set( 

670 self, 

671 section: SectionLike, 

672 name: NameLike, 

673 value: ValueLike | bool, 

674 ) -> None: 

675 """Set a configuration value. 

676 

677 Args: 

678 section: Section name 

679 name: Setting name 

680 value: Configuration value 

681 """ 

682 section, name = self._check_section_and_name(section, name) 

683 

684 if isinstance(value, bool): 

685 value = b"true" if value else b"false" 

686 

687 if not isinstance(value, bytes): 

688 value = value.encode(self.encoding) 

689 

690 section_dict = self._values.setdefault(section) 

691 if hasattr(section_dict, "set"): 

692 section_dict.set(name, value) 

693 else: 

694 section_dict[name] = value 

695 

696 def add( 

697 self, 

698 section: SectionLike, 

699 name: NameLike, 

700 value: ValueLike | bool, 

701 ) -> None: 

702 """Add a value to a configuration setting, creating a multivar if needed.""" 

703 section, name = self._check_section_and_name(section, name) 

704 

705 if isinstance(value, bool): 

706 value = b"true" if value else b"false" 

707 

708 if not isinstance(value, bytes): 

709 value = value.encode(self.encoding) 

710 

711 self._values.setdefault(section)[name] = value 

712 

713 def remove(self, section: SectionLike, name: NameLike) -> None: 

714 """Remove a configuration setting. 

715 

716 Args: 

717 section: Section name 

718 name: Setting name 

719 

720 Raises: 

721 KeyError: If the section or name doesn't exist 

722 """ 

723 section, name = self._check_section_and_name(section, name) 

724 del self._values[section][name] 

725 

726 def items(self, section: SectionLike) -> Iterator[tuple[Name, Value]]: 

727 """Get items in a section.""" 

728 section_bytes, _ = self._check_section_and_name(section, b"") 

729 section_dict = self._values.get(section_bytes) 

730 if section_dict is not None: 

731 return iter(section_dict.items()) 

732 return iter([]) 

733 

734 def sections(self) -> Iterator[Section]: 

735 """Get all sections.""" 

736 return iter(self._values.keys()) 

737 

738 

739def _format_string(value: bytes) -> bytes: 

740 if ( 

741 value.startswith((b" ", b"\t")) 

742 or value.endswith((b" ", b"\t")) 

743 or b"#" in value 

744 ): 

745 return b'"' + _escape_value(value) + b'"' 

746 else: 

747 return _escape_value(value) 

748 

749 

750_ESCAPE_TABLE = { 

751 ord(b"\\"): ord(b"\\"), 

752 ord(b'"'): ord(b'"'), 

753 ord(b"n"): ord(b"\n"), 

754 ord(b"t"): ord(b"\t"), 

755 ord(b"b"): ord(b"\b"), 

756} 

757_COMMENT_CHARS = [ord(b"#"), ord(b";")] 

758_WHITESPACE_CHARS = [ord(b"\t"), ord(b" ")] 

759 

760 

761def _parse_string(value: bytes) -> bytes: 

762 value_array = bytearray(value.strip()) 

763 ret = bytearray() 

764 whitespace = bytearray() 

765 in_quotes = False 

766 i = 0 

767 while i < len(value_array): 

768 c = value_array[i] 

769 if c == ord(b"\\"): 

770 i += 1 

771 if i >= len(value_array): 

772 # Backslash at end of string - treat as literal backslash 

773 if whitespace: 

774 ret.extend(whitespace) 

775 whitespace = bytearray() 

776 ret.append(ord(b"\\")) 

777 else: 

778 try: 

779 v = _ESCAPE_TABLE[value_array[i]] 

780 if whitespace: 

781 ret.extend(whitespace) 

782 whitespace = bytearray() 

783 ret.append(v) 

784 except KeyError: 

785 # Unknown escape sequence - treat backslash as literal and process next char normally 

786 if whitespace: 

787 ret.extend(whitespace) 

788 whitespace = bytearray() 

789 ret.append(ord(b"\\")) 

790 i -= 1 # Reprocess the character after the backslash 

791 elif c == ord(b'"'): 

792 in_quotes = not in_quotes 

793 elif c in _COMMENT_CHARS and not in_quotes: 

794 # the rest of the line is a comment 

795 break 

796 elif c in _WHITESPACE_CHARS: 

797 if in_quotes: 

798 ret.append(c) 

799 else: 

800 whitespace.append(c) 

801 else: 

802 if whitespace: 

803 ret.extend(whitespace) 

804 whitespace = bytearray() 

805 ret.append(c) 

806 i += 1 

807 

808 if in_quotes: 

809 raise ValueError("missing end quote") 

810 

811 return bytes(ret) 

812 

813 

814def _escape_value(value: bytes) -> bytes: 

815 """Escape a value.""" 

816 value = value.replace(b"\\", b"\\\\") 

817 value = value.replace(b"\r", b"\\r") 

818 value = value.replace(b"\n", b"\\n") 

819 value = value.replace(b"\t", b"\\t") 

820 value = value.replace(b'"', b'\\"') 

821 return value 

822 

823 

824def _escape_subsection(name: bytes) -> bytes: 

825 r"""Escape a subsection name for writing in a section header. 

826 

827 Per git-config: inside the quoted subsection name, only ``"`` and ``\`` 

828 need (and may) be escaped; newline and NUL are not permitted at all. 

829 """ 

830 if b"\n" in name or b"\0" in name: 

831 raise ValueError(f"subsection name {name!r} contains a forbidden character") 

832 return name.replace(b"\\", b"\\\\").replace(b'"', b'\\"') 

833 

834 

835def _unescape_subsection(name: bytes) -> bytes: 

836 r"""Unescape a quoted subsection name read from a section header. 

837 

838 Per git-config, ``\"`` and ``\\`` are the only recognised escapes. 

839 Git silently drops the backslash on any other ``\x`` sequence, so we 

840 match that lenient behaviour to stay compatible with config files 

841 written by git or by hand (notably ``includeIf`` headers containing 

842 Windows paths where backslashes were not doubled). 

843 """ 

844 out = bytearray() 

845 i = 0 

846 while i < len(name): 

847 c = name[i : i + 1] 

848 if c == b"\\" and i + 1 < len(name): 

849 out += name[i + 1 : i + 2] 

850 i += 2 

851 else: 

852 out += c 

853 i += 1 

854 return bytes(out) 

855 

856 

857def _check_variable_name(name: bytes) -> bool: 

858 for i in range(len(name)): 

859 c = name[i : i + 1] 

860 if not c.isalnum() and c != b"-": 

861 return False 

862 return True 

863 

864 

865def _check_section_name(name: bytes) -> bool: 

866 for i in range(len(name)): 

867 c = name[i : i + 1] 

868 if not c.isalnum() and c not in (b"-", b"."): 

869 return False 

870 return True 

871 

872 

873def _strip_comments(line: bytes) -> bytes: 

874 comment_bytes = {ord(b"#"), ord(b";")} 

875 quote = ord(b'"') 

876 string_open = False 

877 # Normalize line to bytearray for simple 2/3 compatibility 

878 for i, character in enumerate(bytearray(line)): 

879 # Comment characters outside balanced quotes denote comment start 

880 if character == quote: 

881 string_open = not string_open 

882 elif not string_open and character in comment_bytes: 

883 return line[:i] 

884 return line 

885 

886 

887def _is_line_continuation(value: bytes) -> bool: 

888 """Check if a value ends with a line continuation backslash. 

889 

890 A line continuation occurs when a line ends with a backslash that is: 

891 1. Not escaped (not preceded by another backslash) 

892 2. Not within quotes 

893 

894 Args: 

895 value: The value to check 

896 

897 Returns: 

898 True if the value ends with a line continuation backslash 

899 """ 

900 if not value.endswith((b"\\\n", b"\\\r\n")): 

901 return False 

902 

903 # Remove only the newline characters, keep the content including the backslash 

904 if value.endswith(b"\\\r\n"): 

905 content = value[:-2] # Remove \r\n, keep the \ 

906 else: 

907 content = value[:-1] # Remove \n, keep the \ 

908 

909 if not content.endswith(b"\\"): 

910 return False 

911 

912 # Count consecutive backslashes at the end 

913 backslash_count = 0 

914 for i in range(len(content) - 1, -1, -1): 

915 if content[i : i + 1] == b"\\": 

916 backslash_count += 1 

917 else: 

918 break 

919 

920 # If we have an odd number of backslashes, the last one is a line continuation 

921 # If we have an even number, they are all escaped and there's no continuation 

922 return backslash_count % 2 == 1 

923 

924 

925def _parse_section_header_line(line: bytes) -> tuple[Section, bytes]: 

926 # Parse section header ("[bla]") 

927 line = _strip_comments(line).rstrip() 

928 in_quotes = False 

929 escaped = False 

930 for i, c in enumerate(line): 

931 if escaped: 

932 escaped = False 

933 continue 

934 if c == ord(b'"'): 

935 in_quotes = not in_quotes 

936 if c == ord(b"\\"): 

937 escaped = True 

938 if c == ord(b"]") and not in_quotes: 

939 last = i 

940 break 

941 else: 

942 raise ValueError("expected trailing ]") 

943 pts = line[1:last].split(b" ", 1) 

944 line = line[last + 1 :] 

945 section: Section 

946 if len(pts) == 2: 

947 # Handle subsections - Git allows more complex syntax for certain sections like includeIf 

948 if pts[1][:1] == b'"' and pts[1][-1:] == b'"': 

949 # Standard quoted subsection 

950 pts[1] = _unescape_subsection(pts[1][1:-1]) 

951 elif pts[0] == b"includeIf": 

952 # Special handling for includeIf sections which can have complex conditions 

953 # Git allows these without strict quote validation 

954 pts[1] = pts[1].strip() 

955 if pts[1][:1] == b'"' and pts[1][-1:] == b'"': 

956 pts[1] = _unescape_subsection(pts[1][1:-1]) 

957 else: 

958 # Other sections must have quoted subsections 

959 raise ValueError(f"Invalid subsection {pts[1]!r}") 

960 if not _check_section_name(pts[0]): 

961 raise ValueError(f"invalid section name {pts[0]!r}") 

962 section = (pts[0], pts[1]) 

963 else: 

964 if not _check_section_name(pts[0]): 

965 raise ValueError(f"invalid section name {pts[0]!r}") 

966 pts = pts[0].split(b".", 1) 

967 if len(pts) == 2: 

968 section = (pts[0], pts[1]) 

969 else: 

970 section = (pts[0],) 

971 return section, line 

972 

973 

974class ConfigFile(ConfigDict): 

975 """A Git configuration file, like .git/config or ~/.gitconfig.""" 

976 

977 def __init__( 

978 self, 

979 values: MutableMapping[Section, CaseInsensitiveOrderedMultiDict[Name, Value]] 

980 | None = None, 

981 encoding: str | None = None, 

982 ) -> None: 

983 """Initialize a ConfigFile. 

984 

985 Args: 

986 values: Optional mapping of configuration values 

987 encoding: Optional encoding for the file (defaults to system encoding) 

988 """ 

989 super().__init__(values=values, encoding=encoding) 

990 self.path: str | None = None 

991 self._included_paths: set[str] = set() # Track included files to prevent cycles 

992 

993 @classmethod 

994 def from_file( 

995 cls, 

996 f: IO[bytes], 

997 *, 

998 config_dir: str | None = None, 

999 included_paths: set[str] | None = None, 

1000 include_depth: int = 0, 

1001 max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH, 

1002 file_opener: FileOpener | None = None, 

1003 condition_matchers: Mapping[str, ConditionMatcher] | None = None, 

1004 ) -> "ConfigFile": 

1005 """Read configuration from a file-like object. 

1006 

1007 Args: 

1008 f: File-like object to read from 

1009 config_dir: Directory containing the config file (for relative includes) 

1010 included_paths: Set of already included paths (to prevent cycles) 

1011 include_depth: Current include depth (to prevent infinite recursion) 

1012 max_include_depth: Maximum allowed include depth 

1013 file_opener: Optional callback to open included files 

1014 condition_matchers: Optional dict of condition matchers for includeIf 

1015 """ 

1016 if include_depth > max_include_depth: 

1017 # Prevent excessive recursion 

1018 raise ValueError(f"Maximum include depth ({max_include_depth}) exceeded") 

1019 

1020 ret = cls() 

1021 if included_paths is not None: 

1022 ret._included_paths = included_paths.copy() 

1023 

1024 section: Section | None = None 

1025 setting = None 

1026 continuation = None 

1027 for lineno, line in enumerate(f.readlines()): 

1028 if lineno == 0 and line.startswith(b"\xef\xbb\xbf"): 

1029 line = line[3:] 

1030 line = line.lstrip() 

1031 if setting is None: 

1032 if len(line) > 0 and line[:1] == b"[": 

1033 section, line = _parse_section_header_line(line) 

1034 ret._values.setdefault(section) 

1035 if _strip_comments(line).strip() == b"": 

1036 continue 

1037 if section is None: 

1038 raise ValueError(f"setting {line!r} without section") 

1039 try: 

1040 setting, value = line.split(b"=", 1) 

1041 except ValueError: 

1042 setting = line 

1043 value = b"true" 

1044 setting = setting.strip() 

1045 if not _check_variable_name(setting): 

1046 raise ValueError(f"invalid variable name {setting!r}") 

1047 if _is_line_continuation(value): 

1048 if value.endswith(b"\\\r\n"): 

1049 continuation = value[:-3] 

1050 else: 

1051 continuation = value[:-2] 

1052 else: 

1053 continuation = None 

1054 value = _parse_string(value) 

1055 ret._values[section][setting] = value 

1056 

1057 # Process include/includeIf directives 

1058 ret._handle_include_directive( 

1059 section, 

1060 setting, 

1061 value, 

1062 config_dir=config_dir, 

1063 include_depth=include_depth, 

1064 max_include_depth=max_include_depth, 

1065 file_opener=file_opener, 

1066 condition_matchers=condition_matchers, 

1067 ) 

1068 

1069 setting = None 

1070 else: # continuation line 

1071 assert continuation is not None 

1072 if _is_line_continuation(line): 

1073 if line.endswith(b"\\\r\n"): 

1074 continuation += line[:-3] 

1075 else: 

1076 continuation += line[:-2] 

1077 else: 

1078 continuation += line 

1079 value = _parse_string(continuation) 

1080 assert section is not None # Already checked above 

1081 ret._values[section][setting] = value 

1082 

1083 # Process include/includeIf directives 

1084 ret._handle_include_directive( 

1085 section, 

1086 setting, 

1087 value, 

1088 config_dir=config_dir, 

1089 include_depth=include_depth, 

1090 max_include_depth=max_include_depth, 

1091 file_opener=file_opener, 

1092 condition_matchers=condition_matchers, 

1093 ) 

1094 

1095 continuation = None 

1096 setting = None 

1097 return ret 

1098 

1099 def _handle_include_directive( 

1100 self, 

1101 section: Section | None, 

1102 setting: bytes, 

1103 value: bytes, 

1104 *, 

1105 config_dir: str | None, 

1106 include_depth: int, 

1107 max_include_depth: int, 

1108 file_opener: FileOpener | None, 

1109 condition_matchers: Mapping[str, ConditionMatcher] | None, 

1110 ) -> None: 

1111 """Handle include/includeIf directives during config parsing.""" 

1112 if ( 

1113 section is not None 

1114 and setting == b"path" 

1115 and ( 

1116 section[0].lower() == b"include" 

1117 or (len(section) > 1 and section[0].lower() == b"includeif") 

1118 ) 

1119 ): 

1120 self._process_include( 

1121 section, 

1122 value, 

1123 config_dir=config_dir, 

1124 include_depth=include_depth, 

1125 max_include_depth=max_include_depth, 

1126 file_opener=file_opener, 

1127 condition_matchers=condition_matchers, 

1128 ) 

1129 

1130 def _process_include( 

1131 self, 

1132 section: Section, 

1133 path_value: bytes, 

1134 *, 

1135 config_dir: str | None, 

1136 include_depth: int, 

1137 max_include_depth: int, 

1138 file_opener: FileOpener | None, 

1139 condition_matchers: Mapping[str, ConditionMatcher] | None, 

1140 ) -> None: 

1141 """Process an include or includeIf directive.""" 

1142 path_str = path_value.decode(self.encoding, errors="replace") 

1143 

1144 # Handle includeIf conditions 

1145 if len(section) > 1 and section[0].lower() == b"includeif": 

1146 condition = section[1].decode(self.encoding, errors="replace") 

1147 if not self._evaluate_includeif_condition( 

1148 condition, config_dir, condition_matchers 

1149 ): 

1150 return 

1151 

1152 # Resolve the include path 

1153 include_path = self._resolve_include_path(path_str, config_dir) 

1154 if not include_path: 

1155 return 

1156 

1157 # Check for circular includes 

1158 try: 

1159 abs_path = str(Path(include_path).resolve()) 

1160 except (OSError, ValueError) as e: 

1161 # Invalid path - log and skip 

1162 logger.debug("Invalid include path %r: %s", include_path, e) 

1163 return 

1164 if abs_path in self._included_paths: 

1165 return 

1166 

1167 # Load and merge the included file 

1168 try: 

1169 # Use provided file opener or default to GitFile 

1170 opener: FileOpener 

1171 if file_opener is None: 

1172 

1173 def opener(path: str | os.PathLike[str]) -> IO[bytes]: 

1174 return GitFile(path, "rb") 

1175 else: 

1176 opener = file_opener 

1177 

1178 f = opener(include_path) 

1179 except (OSError, ValueError) as e: 

1180 # Git silently ignores missing or unreadable include files 

1181 # Log for debugging purposes 

1182 logger.debug("Invalid include path %r: %s", include_path, e) 

1183 else: 

1184 with f as included_file: 

1185 # Track this path to prevent cycles 

1186 self._included_paths.add(abs_path) 

1187 

1188 # Parse the included file 

1189 included_config = ConfigFile.from_file( 

1190 included_file, 

1191 config_dir=os.path.dirname(include_path), 

1192 included_paths=self._included_paths, 

1193 include_depth=include_depth + 1, 

1194 max_include_depth=max_include_depth, 

1195 file_opener=file_opener, 

1196 condition_matchers=condition_matchers, 

1197 ) 

1198 

1199 # Merge the included configuration 

1200 self._merge_config(included_config) 

1201 

1202 def _merge_config(self, other: "ConfigFile") -> None: 

1203 """Merge another config file into this one.""" 

1204 for section, values in other._values.items(): 

1205 if section not in self._values: 

1206 self._values[section] = CaseInsensitiveOrderedMultiDict() 

1207 for key, value in values.items(): 

1208 self._values[section][key] = value 

1209 

1210 def _resolve_include_path(self, path: str, config_dir: str | None) -> str | None: 

1211 """Resolve an include path to an absolute path.""" 

1212 # Expand ~ to home directory 

1213 path = os.path.expanduser(path) 

1214 

1215 # If path is relative and we have a config directory, make it relative to that 

1216 if not os.path.isabs(path) and config_dir: 

1217 path = os.path.join(config_dir, path) 

1218 

1219 return path 

1220 

1221 def _evaluate_includeif_condition( 

1222 self, 

1223 condition: str, 

1224 config_dir: str | None = None, 

1225 condition_matchers: Mapping[str, ConditionMatcher] | None = None, 

1226 ) -> bool: 

1227 """Evaluate an includeIf condition.""" 

1228 # Try custom matchers first if provided 

1229 if condition_matchers: 

1230 for prefix, matcher in condition_matchers.items(): 

1231 if condition.startswith(prefix): 

1232 return matcher(condition[len(prefix) :]) 

1233 

1234 # Fall back to built-in matchers 

1235 if condition.startswith("hasconfig:"): 

1236 return self._evaluate_hasconfig_condition(condition[10:]) 

1237 else: 

1238 # Unknown condition type - log and ignore (Git behavior) 

1239 logger.debug("Unknown includeIf condition: %r", condition) 

1240 return False 

1241 

1242 def _evaluate_hasconfig_condition(self, condition: str) -> bool: 

1243 """Evaluate a hasconfig condition. 

1244 

1245 Format: hasconfig:config.key:pattern 

1246 Example: hasconfig:remote.*.url:ssh://org-*@github.com/** 

1247 """ 

1248 # Split on the first colon to separate config key from pattern 

1249 parts = condition.split(":", 1) 

1250 if len(parts) != 2: 

1251 logger.debug("Invalid hasconfig condition format: %r", condition) 

1252 return False 

1253 

1254 config_key, pattern = parts 

1255 

1256 # Parse the config key to get section and name 

1257 key_parts = config_key.split(".", 2) 

1258 if len(key_parts) < 2: 

1259 logger.debug("Invalid hasconfig config key: %r", config_key) 

1260 return False 

1261 

1262 # Handle wildcards in section names (e.g., remote.*) 

1263 if len(key_parts) == 3 and key_parts[1] == "*": 

1264 # Match any subsection 

1265 section_prefix = key_parts[0].encode(self.encoding) 

1266 name = key_parts[2].encode(self.encoding) 

1267 

1268 # Check all sections that match the pattern 

1269 for section in self.sections(): 

1270 if len(section) == 2 and section[0] == section_prefix: 

1271 try: 

1272 values = list(self.get_multivar(section, name)) 

1273 for value in values: 

1274 if self._match_hasconfig_pattern(value, pattern): 

1275 return True 

1276 except KeyError: 

1277 continue 

1278 else: 

1279 # Direct section lookup 

1280 if len(key_parts) == 2: 

1281 section = (key_parts[0].encode(self.encoding),) 

1282 name = key_parts[1].encode(self.encoding) 

1283 else: 

1284 section = ( 

1285 key_parts[0].encode(self.encoding), 

1286 key_parts[1].encode(self.encoding), 

1287 ) 

1288 name = key_parts[2].encode(self.encoding) 

1289 

1290 try: 

1291 values = list(self.get_multivar(section, name)) 

1292 for value in values: 

1293 if self._match_hasconfig_pattern(value, pattern): 

1294 return True 

1295 except KeyError: 

1296 pass 

1297 

1298 return False 

1299 

1300 def _match_hasconfig_pattern(self, value: bytes, pattern: str) -> bool: 

1301 """Match a config value against a hasconfig pattern. 

1302 

1303 Supports simple glob patterns like ``*`` and ``**``. 

1304 """ 

1305 value_str = value.decode(self.encoding, errors="replace") 

1306 return match_glob_pattern(value_str, pattern) 

1307 

1308 @classmethod 

1309 def from_path( 

1310 cls, 

1311 path: str | os.PathLike[str], 

1312 *, 

1313 max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH, 

1314 file_opener: FileOpener | None = None, 

1315 condition_matchers: Mapping[str, ConditionMatcher] | None = None, 

1316 ) -> "ConfigFile": 

1317 """Read configuration from a file on disk. 

1318 

1319 Args: 

1320 path: Path to the configuration file 

1321 max_include_depth: Maximum allowed include depth 

1322 file_opener: Optional callback to open included files 

1323 condition_matchers: Optional dict of condition matchers for includeIf 

1324 """ 

1325 abs_path = os.fspath(path) 

1326 config_dir = os.path.dirname(abs_path) 

1327 

1328 # Use provided file opener or default to GitFile 

1329 opener: FileOpener 

1330 if file_opener is None: 

1331 

1332 def opener(p: str | os.PathLike[str]) -> IO[bytes]: 

1333 return GitFile(p, "rb") 

1334 else: 

1335 opener = file_opener 

1336 

1337 with opener(abs_path) as f: 

1338 ret = cls.from_file( 

1339 f, 

1340 config_dir=config_dir, 

1341 max_include_depth=max_include_depth, 

1342 file_opener=file_opener, 

1343 condition_matchers=condition_matchers, 

1344 ) 

1345 ret.path = abs_path 

1346 return ret 

1347 

1348 def write_to_path(self, path: str | os.PathLike[str] | None = None) -> None: 

1349 """Write configuration to a file on disk.""" 

1350 if path is None: 

1351 if self.path is None: 

1352 raise ValueError("No path specified and no default path available") 

1353 path_to_use: str | os.PathLike[str] = self.path 

1354 else: 

1355 path_to_use = path 

1356 with GitFile(path_to_use, "wb") as f: 

1357 self.write_to_file(f) 

1358 

1359 def write_to_file(self, f: IO[bytes] | _GitFile) -> None: 

1360 """Write configuration to a file-like object.""" 

1361 for section, values in self._values.items(): 

1362 try: 

1363 section_name, subsection_name = section 

1364 except ValueError: 

1365 (section_name,) = section 

1366 subsection_name = None 

1367 if subsection_name is None: 

1368 f.write(b"[" + section_name + b"]\n") 

1369 else: 

1370 f.write( 

1371 b"[" 

1372 + section_name 

1373 + b' "' 

1374 + _escape_subsection(subsection_name) 

1375 + b'"]\n' 

1376 ) 

1377 for key, value in values.items(): 

1378 value = _format_string(value) 

1379 f.write(b"\t" + key + b" = " + value + b"\n") 

1380 

1381 

1382def get_xdg_config_home_path(*path_segments: str) -> str: 

1383 """Get a path in the XDG config home directory. 

1384 

1385 Args: 

1386 *path_segments: Path segments to join to the XDG config home 

1387 

1388 Returns: 

1389 Full path in XDG config home directory 

1390 """ 

1391 xdg_config_home = os.environ.get( 

1392 "XDG_CONFIG_HOME", 

1393 os.path.expanduser("~/.config/"), 

1394 ) 

1395 return os.path.join(xdg_config_home, *path_segments) 

1396 

1397 

1398def _find_git_in_win_path() -> Iterator[str]: 

1399 for exe in ("git.exe", "git.cmd"): 

1400 for path in os.environ.get("PATH", "").split(";"): 

1401 if os.path.exists(os.path.join(path, exe)): 

1402 # in windows native shells (powershell/cmd) exe path is 

1403 # .../Git/bin/git.exe or .../Git/cmd/git.exe 

1404 # 

1405 # in git-bash exe path is .../Git/mingw64/bin/git.exe 

1406 git_dir, _bin_dir = os.path.split(path) 

1407 yield git_dir 

1408 parent_dir, basename = os.path.split(git_dir) 

1409 if basename == "mingw32" or basename == "mingw64": 

1410 yield parent_dir 

1411 break 

1412 

1413 

1414def _find_git_in_win_reg() -> Iterator[str]: 

1415 import platform 

1416 import winreg 

1417 

1418 if platform.machine() == "AMD64": 

1419 subkey = ( 

1420 "SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\" 

1421 "CurrentVersion\\Uninstall\\Git_is1" 

1422 ) 

1423 else: 

1424 subkey = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Git_is1" 

1425 

1426 for key in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE): # type: ignore[attr-defined,unused-ignore] 

1427 with suppress(OSError): 

1428 with winreg.OpenKey(key, subkey) as k: # type: ignore[attr-defined,unused-ignore] 

1429 val, typ = winreg.QueryValueEx(k, "InstallLocation") # type: ignore[attr-defined,unused-ignore] 

1430 if typ == winreg.REG_SZ: # type: ignore[attr-defined,unused-ignore] 

1431 yield val 

1432 

1433 

1434# There is no set standard for system config dirs on windows. We try the 

1435# following: 

1436# - %PROGRAMFILES%/Git/etc/gitconfig - Git for Windows (msysgit) config dir 

1437# Used if CGit installation (Git/bin/git.exe) is found in PATH in the 

1438# system registry 

1439def get_win_system_paths() -> Iterator[str]: 

1440 """Get current Windows system Git config paths. 

1441 

1442 Only returns the current Git for Windows config location, not legacy paths. 

1443 """ 

1444 # Try to find Git installation from PATH first 

1445 for git_dir in _find_git_in_win_path(): 

1446 yield os.path.join(git_dir, "etc", "gitconfig") 

1447 return # Only use the first found path 

1448 

1449 # Fall back to registry if not found in PATH 

1450 for git_dir in _find_git_in_win_reg(): 

1451 yield os.path.join(git_dir, "etc", "gitconfig") 

1452 return # Only use the first found path 

1453 

1454 

1455def get_win_legacy_system_paths() -> Iterator[str]: 

1456 """Get legacy Windows system Git config paths. 

1457 

1458 Returns all possible config paths including deprecated locations. 

1459 This function can be used for diagnostics or migration purposes. 

1460 """ 

1461 # Include deprecated PROGRAMDATA location 

1462 if "PROGRAMDATA" in os.environ: 

1463 yield os.path.join(os.environ["PROGRAMDATA"], "Git", "config") 

1464 

1465 # Include all Git installations found 

1466 for git_dir in _find_git_in_win_path(): 

1467 yield os.path.join(git_dir, "etc", "gitconfig") 

1468 for git_dir in _find_git_in_win_reg(): 

1469 yield os.path.join(git_dir, "etc", "gitconfig") 

1470 

1471 

1472def env_config( 

1473 environ: Mapping[str, str], 

1474) -> "ConfigFile | None": 

1475 """Build a ConfigFile from GIT_CONFIG_COUNT/KEY_n/VALUE_n vars. 

1476 

1477 See git-config(1). Any missing key/value, a key without a dot, or a 

1478 non-numeric or negative ``GIT_CONFIG_COUNT`` is treated as an error. 

1479 Callers that want git's "env overrides everything" precedence should 

1480 prepend the result to their ``StackedConfig.backends``; nothing in 

1481 dulwich consults ``os.environ`` for these variables on its own. 

1482 

1483 Args: 

1484 environ: Mapping to read the variables from (e.g. ``os.environ``). 

1485 

1486 Returns: 

1487 A ConfigFile holding the overrides, or None if GIT_CONFIG_COUNT is 

1488 unset or empty (which git treats as zero pairs). 

1489 """ 

1490 raw_count = environ.get("GIT_CONFIG_COUNT") 

1491 if raw_count is None or raw_count == "": 

1492 return None 

1493 try: 

1494 count = int(raw_count) 

1495 except ValueError: 

1496 raise ValueError(f"bogus count in GIT_CONFIG_COUNT: {raw_count!r}") from None 

1497 if count < 0: 

1498 raise ValueError(f"bogus count in GIT_CONFIG_COUNT: {raw_count!r}") 

1499 

1500 cf = ConfigFile() 

1501 for i in range(count): 

1502 key_var = f"GIT_CONFIG_KEY_{i}" 

1503 value_var = f"GIT_CONFIG_VALUE_{i}" 

1504 try: 

1505 key = environ[key_var] 

1506 except KeyError: 

1507 raise KeyError(f"missing config key {key_var}") from None 

1508 try: 

1509 value = environ[value_var] 

1510 except KeyError: 

1511 raise KeyError(f"missing config value {value_var}") from None 

1512 if "." not in key: 

1513 raise ValueError(f"invalid config format: {key}") 

1514 # Git keys are <section>.<name> or <section>.<subsection>.<name>. 

1515 # The subsection (if present) may itself contain dots. 

1516 first_dot = key.find(".") 

1517 last_dot = key.rfind(".") 

1518 section_name = key[:first_dot] 

1519 name = key[last_dot + 1 :] 

1520 if first_dot == last_dot: 

1521 section: Section = (section_name.encode("utf-8"),) 

1522 else: 

1523 subsection = key[first_dot + 1 : last_dot] 

1524 section = ( 

1525 section_name.encode("utf-8"), 

1526 subsection.encode("utf-8"), 

1527 ) 

1528 cf.add(section, name.encode("utf-8"), value.encode("utf-8")) 

1529 return cf 

1530 

1531 

1532class StackedConfig(Config): 

1533 """Configuration which reads from multiple config files..""" 

1534 

1535 def __init__( 

1536 self, backends: list[ConfigFile], writable: ConfigFile | None = None 

1537 ) -> None: 

1538 """Initialize a StackedConfig. 

1539 

1540 Args: 

1541 backends: List of config files to read from (in order of precedence) 

1542 writable: Optional config file to write changes to 

1543 """ 

1544 self.backends = backends 

1545 self.writable = writable 

1546 

1547 def __repr__(self) -> str: 

1548 """Return string representation of StackedConfig.""" 

1549 return f"<{self.__class__.__name__} for {self.backends!r}>" 

1550 

1551 @classmethod 

1552 def default(cls) -> "StackedConfig": 

1553 """Create a StackedConfig with default system/user config files. 

1554 

1555 Returns: 

1556 StackedConfig with default configuration files loaded 

1557 """ 

1558 return cls(cls.default_backends()) 

1559 

1560 @classmethod 

1561 def default_backends(cls) -> list[ConfigFile]: 

1562 """Retrieve the default configuration. 

1563 

1564 See git-config(1) for details on the files searched. 

1565 """ 

1566 paths = [] 

1567 

1568 # Handle GIT_CONFIG_GLOBAL - overrides user config paths 

1569 try: 

1570 paths.append(os.environ["GIT_CONFIG_GLOBAL"]) 

1571 except KeyError: 

1572 paths.append(os.path.expanduser("~/.gitconfig")) 

1573 paths.append(get_xdg_config_home_path("git", "config")) 

1574 

1575 # Handle GIT_CONFIG_SYSTEM and GIT_CONFIG_NOSYSTEM 

1576 try: 

1577 paths.append(os.environ["GIT_CONFIG_SYSTEM"]) 

1578 except KeyError: 

1579 if "GIT_CONFIG_NOSYSTEM" not in os.environ: 

1580 paths.append("/etc/gitconfig") 

1581 if sys.platform == "win32": 

1582 paths.extend(get_win_system_paths()) 

1583 

1584 logger.debug("Loading gitconfig from paths: %s", paths) 

1585 

1586 backends = [] 

1587 for path in paths: 

1588 try: 

1589 cf = ConfigFile.from_path(path) 

1590 logger.debug("Successfully loaded gitconfig from: %s", path) 

1591 except FileNotFoundError: 

1592 logger.debug("Gitconfig file not found: %s", path) 

1593 continue 

1594 backends.append(cf) 

1595 

1596 return backends 

1597 

1598 def get(self, section: SectionLike, name: NameLike) -> Value: 

1599 """Get value from configuration.""" 

1600 if not isinstance(section, tuple): 

1601 section = (section,) 

1602 for backend in self.backends: 

1603 try: 

1604 return backend.get(section, name) 

1605 except KeyError: 

1606 pass 

1607 raise KeyError(name) 

1608 

1609 def get_multivar(self, section: SectionLike, name: NameLike) -> Iterator[Value]: 

1610 """Get multiple values from configuration.""" 

1611 if not isinstance(section, tuple): 

1612 section = (section,) 

1613 for backend in self.backends: 

1614 try: 

1615 yield from backend.get_multivar(section, name) 

1616 except KeyError: 

1617 pass 

1618 

1619 def set( 

1620 self, section: SectionLike, name: NameLike, value: ValueLike | bool 

1621 ) -> None: 

1622 """Set value in configuration.""" 

1623 if self.writable is None: 

1624 raise NotImplementedError(self.set) 

1625 return self.writable.set(section, name, value) 

1626 

1627 def sections(self) -> Iterator[Section]: 

1628 """Get all sections.""" 

1629 seen = set() 

1630 for backend in self.backends: 

1631 for section in backend.sections(): 

1632 if section not in seen: 

1633 seen.add(section) 

1634 yield section 

1635 

1636 

1637def read_submodules( 

1638 path: str | os.PathLike[str], 

1639) -> Iterator[tuple[bytes, bytes, bytes]]: 

1640 """Read a .gitmodules file.""" 

1641 cfg = ConfigFile.from_path(path) 

1642 return parse_submodules(cfg) 

1643 

1644 

1645def parse_submodules(config: ConfigFile) -> Iterator[tuple[bytes, bytes, bytes]]: 

1646 """Parse a gitmodules GitConfig file, returning submodules. 

1647 

1648 Args: 

1649 config: A `ConfigFile` 

1650 Returns: 

1651 list of tuples (submodule path, url, name), 

1652 where name is quoted part of the section's name. 

1653 """ 

1654 for section in config.sections(): 

1655 section_kind, section_name = section 

1656 if section_kind == b"submodule": 

1657 try: 

1658 sm_path = config.get(section, b"path") 

1659 sm_url = config.get(section, b"url") 

1660 yield (sm_path, sm_url, section_name) 

1661 except KeyError: 

1662 # If either path or url is missing, just ignore this 

1663 # submodule entry and move on to the next one. This is 

1664 # how git itself handles malformed .gitmodule entries. 

1665 pass 

1666 

1667 

1668def iter_instead_of(config: Config, push: bool = False) -> Iterable[tuple[str, str]]: 

1669 """Iterate over insteadOf / pushInsteadOf values.""" 

1670 for section in config.sections(): 

1671 if section[0] != b"url": 

1672 continue 

1673 replacement = section[1] 

1674 try: 

1675 needles = list(config.get_multivar(section, "insteadOf")) 

1676 except KeyError: 

1677 needles = [] 

1678 if push: 

1679 try: 

1680 needles += list(config.get_multivar(section, "pushInsteadOf")) 

1681 except KeyError: 

1682 pass 

1683 for needle in needles: 

1684 assert isinstance(needle, bytes) 

1685 yield needle.decode("utf-8"), replacement.decode("utf-8") 

1686 

1687 

1688def get_git_proxy_command(config: Config, host: str) -> str | None: 

1689 """Look up the core.gitProxy command for the given host. 

1690 

1691 The ``core.gitProxy`` variable can appear multiple times, each with an 

1692 optional ``for <domain>`` suffix. The first entry whose domain suffix 

1693 matches the end of *host* wins; an entry without a ``for`` clause is a 

1694 catch-all default. 

1695 

1696 Args: 

1697 config: A Config instance. 

1698 host: The hostname being connected to. 

1699 

1700 Returns: 

1701 The proxy command string, or ``None`` if no proxy is configured. 

1702 """ 

1703 try: 

1704 values = list(config.get_multivar((b"core",), b"gitProxy")) 

1705 except KeyError: 

1706 return None 

1707 

1708 default_proxy: str | None = None 

1709 for raw in values: 

1710 text = raw.decode("utf-8") if isinstance(raw, bytes) else raw 

1711 # Entries may look like: 

1712 # proxy-command for kernel.org 

1713 # default-proxy 

1714 parts = text.rsplit(" for ", 1) 

1715 if len(parts) == 2: 

1716 command, domain = parts[0].strip(), parts[1].strip() 

1717 if host == domain or host.endswith("." + domain): 

1718 return command 

1719 else: 

1720 default_proxy = text.strip() 

1721 

1722 return default_proxy 

1723 

1724 

1725def apply_instead_of(config: Config, orig_url: str, push: bool = False) -> str: 

1726 """Apply insteadOf / pushInsteadOf to a URL.""" 

1727 longest_needle = "" 

1728 updated_url = orig_url 

1729 for needle, replacement in iter_instead_of(config, push): 

1730 if not orig_url.startswith(needle): 

1731 continue 

1732 if len(longest_needle) < len(needle): 

1733 longest_needle = needle 

1734 updated_url = replacement + orig_url[len(needle) :] 

1735 return updated_url