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

799 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 "Name", 

40 "NameLike", 

41 "Section", 

42 "SectionLike", 

43 "StackedConfig", 

44 "Value", 

45 "ValueLike", 

46 "apply_instead_of", 

47 "env_config", 

48 "get_git_proxy_command", 

49 "get_win_legacy_system_paths", 

50 "get_win_system_paths", 

51 "get_xdg_config_home_path", 

52 "iter_instead_of", 

53 "lower_key", 

54 "match_glob_pattern", 

55 "parse_submodules", 

56 "read_submodules", 

57] 

58 

59import logging 

60import os 

61import re 

62import sys 

63from collections.abc import ( 

64 Callable, 

65 ItemsView, 

66 Iterable, 

67 Iterator, 

68 KeysView, 

69 Mapping, 

70 MutableMapping, 

71 ValuesView, 

72) 

73from contextlib import suppress 

74from pathlib import Path 

75from typing import ( 

76 IO, 

77 Generic, 

78 TypeVar, 

79 overload, 

80) 

81 

82from .file import GitFile, _GitFile 

83 

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

85ConfigValue = str | bytes | bool | int 

86 

87logger = logging.getLogger(__name__) 

88 

89# Type for file opener callback 

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

91 

92# Type for includeIf condition matcher 

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

94ConditionMatcher = Callable[[str], bool] 

95 

96# Security limits for include files 

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

98DEFAULT_MAX_INCLUDE_DEPTH = 10 # Maximum recursion depth for includes 

99 

100 

101def _match_gitdir_pattern( 

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

103) -> bool: 

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

105 

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

107 """ 

108 # Convert to strings for easier manipulation 

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

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

111 

112 # Normalize paths to use forward slashes for consistent matching 

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

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

115 

116 if ignorecase: 

117 path_str = path_str.lower() 

118 pattern_str = pattern_str.lower() 

119 

120 # Handle the common cases for gitdir patterns 

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

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

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

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

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

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

127 # Pattern like **/filename 

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

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

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

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

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

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

134 elif "**" in pattern_str: 

135 # Handle patterns with ** in the middle 

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

137 if len(parts) == 2: 

138 prefix, suffix = parts 

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

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

141 return False 

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

143 return False 

144 return True 

145 

146 # Direct match or simple glob pattern 

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

148 import fnmatch 

149 

150 return fnmatch.fnmatch(path_str, pattern_str) 

151 else: 

152 return path_str == pattern_str 

153 

154 

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

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

157 

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

159 

160 Raises: 

161 ValueError: If the pattern is invalid 

162 """ 

163 # Collapse a run of consecutive '*' to at most '**'. Repeated stars are 

164 # redundant, and translating each into its own quantifier would emit 

165 # adjacent unbounded quantifiers (e.g. '.*.*') that backtrack 

166 # catastrophically on non-matching input (ReDoS). 

167 pattern = re.sub(r"\*\*+", "**", pattern) 

168 # Convert glob pattern to regex 

169 pattern_escaped = re.escape(pattern) 

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

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

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

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

174 # Anchor the pattern 

175 pattern_regex = f"^{pattern_escaped}$" 

176 

177 try: 

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

179 except re.error as e: 

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

181 

182 

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

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

185 

186 Args: 

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

188 

189 Returns: 

190 Key with section names lowercased, subsection names preserved 

191 

192 Raises: 

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

194 """ 

195 if isinstance(key, bytes | str): 

196 return key.lower() 

197 

198 if isinstance(key, tuple): 

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

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

201 if len(key) > 0: 

202 first = key[0] 

203 assert isinstance(first, bytes | str) 

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

205 return key 

206 

207 raise TypeError(key) 

208 

209 

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

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

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

213 

214 

215class _UniqueKeysView(KeysView[K]): 

216 """KeysView backed by an explicit list of keys (used to deduplicate).""" 

217 

218 def __init__(self, keys: list[K]) -> None: 

219 self._keys = keys 

220 

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

222 return key in self._keys 

223 

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

225 return iter(self._keys) 

226 

227 def __len__(self) -> int: 

228 return len(self._keys) 

229 

230 

231class _OrderedItemsView(ItemsView[K, V]): 

232 """Items view backed by the underlying ordered list of a multi-dict.""" 

233 

234 def __init__(self, mapping: "CaseInsensitiveOrderedMultiDict[K, V]") -> None: 

235 self._mapping = mapping 

236 

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

238 return iter(self._mapping._real) 

239 

240 def __len__(self) -> int: 

241 return len(self._mapping._real) 

242 

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

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

245 return False 

246 key, value = item 

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

248 

249 

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

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

252 

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

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

255 """ 

256 

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

258 """Initialize a CaseInsensitiveOrderedMultiDict. 

259 

260 Args: 

261 default_factory: Optional factory function for default values 

262 """ 

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

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

265 self._default_factory = default_factory 

266 

267 @classmethod 

268 def make( 

269 cls, 

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

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

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

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

274 

275 Args: 

276 dict_in: Optional mapping to initialize from 

277 default_factory: Optional factory function for default values 

278 

279 Returns: 

280 New CaseInsensitiveOrderedMultiDict instance 

281 

282 Raises: 

283 TypeError: If dict_in is not a mapping or None 

284 """ 

285 if isinstance(dict_in, cls): 

286 return dict_in 

287 

288 out = cls(default_factory=default_factory) 

289 

290 if dict_in is None: 

291 return out 

292 

293 if not isinstance(dict_in, MutableMapping): 

294 raise TypeError 

295 

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

297 out[key] = value 

298 

299 return out 

300 

301 def __len__(self) -> int: 

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

303 return len(self._keyed) 

304 

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

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

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

308 # We need to deduplicate since _real can have duplicates 

309 seen = set() 

310 unique_keys = [] 

311 for k, _ in self._real: 

312 lower = lower_key(k) 

313 if lower not in seen: 

314 seen.add(lower) 

315 unique_keys.append(k) 

316 return _UniqueKeysView(unique_keys) 

317 

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

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

320 return _OrderedItemsView(self) 

321 

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

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

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

325 seen = set() 

326 for k, _ in self._real: 

327 lower = lower_key(k) 

328 if lower not in seen: 

329 seen.add(lower) 

330 yield k 

331 

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

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

334 return self._keyed.values() 

335 

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

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

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

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

340 

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

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

343 

344 Args: 

345 key: The key to set 

346 value: The value to set 

347 """ 

348 # This method replaces all existing values for the key 

349 lower = lower_key(key) 

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

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

352 self._keyed[lower] = value 

353 

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

355 """Delete all values for a key. 

356 

357 Raises: 

358 KeyError: If the key is not found 

359 """ 

360 lower_k = lower_key(key) 

361 del self._keyed[lower_k] 

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

363 if lower_key(actual) == lower_k: 

364 del self._real[i] 

365 

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

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

368 

369 Raises: 

370 KeyError: If the key is not found 

371 """ 

372 return self._keyed[lower_key(item)] 

373 

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

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

376 

377 Args: 

378 key: The key to look up 

379 default: Default value to return if key not found 

380 

381 Returns: 

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

383 """ 

384 try: 

385 return self[key] 

386 except KeyError: 

387 if default is not None: 

388 return default 

389 elif self._default_factory is not None: 

390 return self._default_factory() 

391 else: 

392 return None 

393 

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

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

396 

397 Args: 

398 key: The key to look up 

399 

400 Returns: 

401 Iterator of all values for the key 

402 """ 

403 lowered_key = lower_key(key) 

404 for actual, value in self._real: 

405 if lower_key(actual) == lowered_key: 

406 yield value 

407 

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

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

410 

411 Args: 

412 key: The key to look up 

413 default: Default value to set if key not found 

414 

415 Returns: 

416 The existing value or the newly set default 

417 

418 Raises: 

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

420 """ 

421 try: 

422 return self[key] 

423 except KeyError: 

424 if default is not None: 

425 self[key] = default 

426 return default 

427 elif self._default_factory is not None: 

428 value = self._default_factory() 

429 self[key] = value 

430 return value 

431 else: 

432 raise 

433 

434 

435Name = bytes 

436NameLike = bytes | str 

437Section = tuple[bytes, ...] 

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

439Value = bytes 

440ValueLike = bytes | str 

441 

442 

443class Config: 

444 """A Git configuration.""" 

445 

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

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

448 

449 Args: 

450 section: Tuple with section name and optional subsection name 

451 name: Variable name 

452 Returns: 

453 Contents of the setting 

454 Raises: 

455 KeyError: if the value is not set 

456 """ 

457 raise NotImplementedError(self.get) 

458 

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

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

461 

462 Args: 

463 section: Tuple with section name and optional subsection namee 

464 name: Variable name 

465 Returns: 

466 Contents of the setting as iterable 

467 Raises: 

468 KeyError: if the value is not set 

469 """ 

470 raise NotImplementedError(self.get_multivar) 

471 

472 @overload 

473 def get_boolean( 

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

475 ) -> bool: ... 

476 

477 @overload 

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

479 

480 def get_boolean( 

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

482 ) -> bool | None: 

483 """Retrieve a configuration setting as boolean. 

484 

485 Args: 

486 section: Tuple with section name and optional subsection name 

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

488 subsection. 

489 default: Default value if setting is not found 

490 

491 Returns: 

492 Contents of the setting 

493 """ 

494 try: 

495 value = self.get(section, name) 

496 except KeyError: 

497 return default 

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

499 return True 

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

501 return False 

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

503 

504 def set( 

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

506 ) -> None: 

507 """Set a configuration value. 

508 

509 Args: 

510 section: Tuple with section name and optional subsection namee 

511 name: Name of the configuration value, including section 

512 and optional subsection 

513 value: value of the setting 

514 """ 

515 raise NotImplementedError(self.set) 

516 

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

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

519 

520 Args: 

521 section: Tuple with section name and optional subsection namee 

522 Returns: 

523 Iterator over (name, value) pairs 

524 """ 

525 raise NotImplementedError(self.items) 

526 

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

528 """Iterate over the sections. 

529 

530 Returns: Iterator over section tuples 

531 """ 

532 raise NotImplementedError(self.sections) 

533 

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

535 """Check if a specified section exists. 

536 

537 Args: 

538 name: Name of section to check for 

539 Returns: 

540 boolean indicating whether the section exists 

541 """ 

542 return name in self.sections() 

543 

544 

545class ConfigDict(Config): 

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

547 

548 def __init__( 

549 self, 

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

551 | None = None, 

552 encoding: str | None = None, 

553 ) -> None: 

554 """Create a new ConfigDict.""" 

555 if encoding is None: 

556 encoding = sys.getdefaultencoding() 

557 self.encoding = encoding 

558 self._values: CaseInsensitiveOrderedMultiDict[ 

559 Section, CaseInsensitiveOrderedMultiDict[Name, Value] 

560 ] = CaseInsensitiveOrderedMultiDict.make( 

561 values, default_factory=CaseInsensitiveOrderedMultiDict 

562 ) 

563 

564 def __repr__(self) -> str: 

565 """Return string representation of ConfigDict.""" 

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

567 

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

569 """Check equality with another ConfigDict.""" 

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

571 

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

573 """Get configuration values for a section. 

574 

575 Raises: 

576 KeyError: If section not found 

577 """ 

578 return self._values.__getitem__(key) 

579 

580 def __setitem__( 

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

582 ) -> None: 

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

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

585 

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

587 """Delete a configuration section. 

588 

589 Raises: 

590 KeyError: If section not found 

591 """ 

592 return self._values.__delitem__(key) 

593 

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

595 """Iterate over configuration sections.""" 

596 return self._values.__iter__() 

597 

598 def __len__(self) -> int: 

599 """Return the number of sections.""" 

600 return self._values.__len__() 

601 

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

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

604 return self._values.keys() 

605 

606 @classmethod 

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

608 parts = name.split(".") 

609 if len(parts) == 3: 

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

611 else: 

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

613 

614 def _check_section_and_name( 

615 self, section: SectionLike, name: NameLike 

616 ) -> tuple[Section, Name]: 

617 if not isinstance(section, tuple): 

618 section = (section,) 

619 

620 checked_section = tuple( 

621 [ 

622 subsection.encode(self.encoding) 

623 if not isinstance(subsection, bytes) 

624 else subsection 

625 for subsection in section 

626 ] 

627 ) 

628 

629 if not isinstance(name, bytes): 

630 name = name.encode(self.encoding) 

631 

632 return checked_section, name 

633 

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

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

636 

637 Args: 

638 section: Section name 

639 name: Setting name 

640 

641 Returns: 

642 Iterator of configuration values 

643 """ 

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

645 assert len(section) >= 1 

646 

647 if len(section) > 1: 

648 try: 

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

650 except KeyError: 

651 pass 

652 

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

654 

655 def get( 

656 self, 

657 section: SectionLike, 

658 name: NameLike, 

659 ) -> Value: 

660 """Get a configuration value. 

661 

662 Args: 

663 section: Section name 

664 name: Setting name 

665 

666 Returns: 

667 Configuration value 

668 

669 Raises: 

670 KeyError: if the value is not set 

671 """ 

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

673 assert len(section) >= 1 

674 

675 if len(section) > 1: 

676 try: 

677 return self._values[section][name] 

678 except KeyError: 

679 pass 

680 

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

682 

683 def set( 

684 self, 

685 section: SectionLike, 

686 name: NameLike, 

687 value: ValueLike | bool, 

688 ) -> None: 

689 """Set a configuration value. 

690 

691 Args: 

692 section: Section name 

693 name: Setting name 

694 value: Configuration value 

695 """ 

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

697 

698 if isinstance(value, bool): 

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

700 

701 if not isinstance(value, bytes): 

702 value = value.encode(self.encoding) 

703 

704 section_dict = self._values.setdefault(section) 

705 if hasattr(section_dict, "set"): 

706 section_dict.set(name, value) 

707 else: 

708 section_dict[name] = value 

709 

710 def add( 

711 self, 

712 section: SectionLike, 

713 name: NameLike, 

714 value: ValueLike | bool, 

715 ) -> None: 

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

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

718 

719 if isinstance(value, bool): 

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

721 

722 if not isinstance(value, bytes): 

723 value = value.encode(self.encoding) 

724 

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

726 

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

728 """Remove a configuration setting. 

729 

730 Args: 

731 section: Section name 

732 name: Setting name 

733 

734 Raises: 

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

736 """ 

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

738 del self._values[section][name] 

739 

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

741 """Get items in a section.""" 

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

743 section_dict = self._values.get(section_bytes) 

744 if section_dict is not None: 

745 return iter(section_dict.items()) 

746 return iter([]) 

747 

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

749 """Get all sections.""" 

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

751 

752 

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

754 if ( 

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

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

757 or b"#" in value 

758 # A carriage return is written literally, so it has to be quoted: 

759 # an unquoted trailing one would be read back as a line ending. 

760 or b"\r" in value 

761 ): 

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

763 else: 

764 return _escape_value(value) 

765 

766 

767_ESCAPE_TABLE = { 

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

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

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

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

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

773} 

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

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

776 

777 

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

779 value_array = bytearray(value.strip()) 

780 ret = bytearray() 

781 whitespace = bytearray() 

782 in_quotes = False 

783 i = 0 

784 while i < len(value_array): 

785 c = value_array[i] 

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

787 i += 1 

788 if i >= len(value_array): 

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

790 if whitespace: 

791 ret.extend(whitespace) 

792 whitespace = bytearray() 

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

794 else: 

795 try: 

796 v = _ESCAPE_TABLE[value_array[i]] 

797 if whitespace: 

798 ret.extend(whitespace) 

799 whitespace = bytearray() 

800 ret.append(v) 

801 except KeyError: 

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

803 if whitespace: 

804 ret.extend(whitespace) 

805 whitespace = bytearray() 

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

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

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

809 in_quotes = not in_quotes 

810 elif c in _COMMENT_CHARS and not in_quotes: 

811 # the rest of the line is a comment 

812 break 

813 elif c in _WHITESPACE_CHARS: 

814 if in_quotes: 

815 ret.append(c) 

816 else: 

817 whitespace.append(c) 

818 else: 

819 if whitespace: 

820 ret.extend(whitespace) 

821 whitespace = bytearray() 

822 ret.append(c) 

823 i += 1 

824 

825 if in_quotes: 

826 raise ValueError("missing end quote") 

827 

828 return bytes(ret) 

829 

830 

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

832 r"""Escape a value. 

833 

834 git only recognizes the \n, \t, \b, \" and \\ escapes; a \r escape makes 

835 it reject the whole file, so a carriage return is written literally 

836 and ``_format_string`` quotes the value instead. 

837 """ 

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

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

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

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

842 return value 

843 

844 

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

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

847 

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

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

850 """ 

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

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

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

854 

855 

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

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

858 

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

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

861 match that lenient behaviour to stay compatible with config files 

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

863 Windows paths where backslashes were not doubled). 

864 """ 

865 out = bytearray() 

866 i = 0 

867 while i < len(name): 

868 c = name[i : i + 1] 

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

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

871 i += 2 

872 else: 

873 out += c 

874 i += 1 

875 return bytes(out) 

876 

877 

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

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

880 c = name[i : i + 1] 

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

882 return False 

883 return True 

884 

885 

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

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

888 c = name[i : i + 1] 

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

890 return False 

891 return True 

892 

893 

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

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

896 quote = ord(b'"') 

897 string_open = False 

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

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

900 # Comment characters outside balanced quotes denote comment start 

901 if character == quote: 

902 string_open = not string_open 

903 elif not string_open and character in comment_bytes: 

904 return line[:i] 

905 return line 

906 

907 

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

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

910 

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

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

913 2. Not within quotes 

914 

915 Args: 

916 value: The value to check 

917 

918 Returns: 

919 True if the value ends with a line continuation backslash 

920 """ 

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

922 return False 

923 

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

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

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

927 else: 

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

929 

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

931 return False 

932 

933 # Count consecutive backslashes at the end 

934 backslash_count = 0 

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

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

937 backslash_count += 1 

938 else: 

939 break 

940 

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

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

943 return backslash_count % 2 == 1 

944 

945 

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

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

948 line = _strip_comments(line).rstrip() 

949 in_quotes = False 

950 escaped = False 

951 for i, c in enumerate(line): 

952 if escaped: 

953 escaped = False 

954 continue 

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

956 in_quotes = not in_quotes 

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

958 escaped = True 

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

960 last = i 

961 break 

962 else: 

963 raise ValueError("expected trailing ]") 

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

965 line = line[last + 1 :] 

966 section: Section 

967 if len(pts) == 2: 

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

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

970 # Standard quoted subsection 

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

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

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

974 # Git allows these without strict quote validation 

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

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

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

978 else: 

979 # Other sections must have quoted subsections 

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

981 if not _check_section_name(pts[0]): 

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

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

984 else: 

985 if not _check_section_name(pts[0]): 

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

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

988 if len(pts) == 2: 

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

990 else: 

991 section = (pts[0],) 

992 return section, line 

993 

994 

995class ConfigFile(ConfigDict): 

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

997 

998 def __init__( 

999 self, 

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

1001 | None = None, 

1002 encoding: str | None = None, 

1003 ) -> None: 

1004 """Initialize a ConfigFile. 

1005 

1006 Args: 

1007 values: Optional mapping of configuration values 

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

1009 """ 

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

1011 self.path: str | None = None 

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

1013 

1014 @classmethod 

1015 def from_file( 

1016 cls, 

1017 f: IO[bytes], 

1018 *, 

1019 config_dir: str | None = None, 

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

1021 include_depth: int = 0, 

1022 max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH, 

1023 file_opener: FileOpener | None = None, 

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

1025 expand_includes: bool = True, 

1026 ) -> "ConfigFile": 

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

1028 

1029 Args: 

1030 f: File-like object to read from 

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

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

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

1034 max_include_depth: Maximum allowed include depth 

1035 file_opener: Optional callback to open included files 

1036 condition_matchers: Optional dict of condition matchers for includeIf 

1037 expand_includes: Whether to honor include/includeIf directives. Set 

1038 to False when parsing untrusted config (e.g. .gitmodules) to 

1039 avoid following include.path to arbitrary files. 

1040 """ 

1041 if include_depth > max_include_depth: 

1042 # Prevent excessive recursion 

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

1044 

1045 ret = cls() 

1046 if included_paths is not None: 

1047 ret._included_paths = included_paths.copy() 

1048 

1049 section: Section | None = None 

1050 setting = None 

1051 continuation = None 

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

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

1054 line = line[3:] 

1055 line = line.lstrip() 

1056 if setting is None: 

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

1058 section, line = _parse_section_header_line(line) 

1059 ret._values.setdefault(section) 

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

1061 continue 

1062 if section is None: 

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

1064 try: 

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

1066 except ValueError: 

1067 setting = line 

1068 value = b"true" 

1069 setting = setting.strip() 

1070 if not _check_variable_name(setting): 

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

1072 if _is_line_continuation(value): 

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

1074 continuation = value[:-3] 

1075 else: 

1076 continuation = value[:-2] 

1077 else: 

1078 continuation = None 

1079 value = _parse_string(value) 

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

1081 

1082 # Process include/includeIf directives 

1083 if expand_includes: 

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 setting = None 

1096 else: # continuation line 

1097 assert continuation is not None 

1098 if _is_line_continuation(line): 

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

1100 continuation += line[:-3] 

1101 else: 

1102 continuation += line[:-2] 

1103 else: 

1104 continuation += line 

1105 value = _parse_string(continuation) 

1106 assert section is not None # Already checked above 

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

1108 

1109 # Process include/includeIf directives 

1110 if expand_includes: 

1111 ret._handle_include_directive( 

1112 section, 

1113 setting, 

1114 value, 

1115 config_dir=config_dir, 

1116 include_depth=include_depth, 

1117 max_include_depth=max_include_depth, 

1118 file_opener=file_opener, 

1119 condition_matchers=condition_matchers, 

1120 ) 

1121 

1122 continuation = None 

1123 setting = None 

1124 return ret 

1125 

1126 def _handle_include_directive( 

1127 self, 

1128 section: Section | None, 

1129 setting: bytes, 

1130 value: bytes, 

1131 *, 

1132 config_dir: str | None, 

1133 include_depth: int, 

1134 max_include_depth: int, 

1135 file_opener: FileOpener | None, 

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

1137 ) -> None: 

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

1139 if ( 

1140 section is not None 

1141 and setting == b"path" 

1142 and ( 

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

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

1145 ) 

1146 ): 

1147 self._process_include( 

1148 section, 

1149 value, 

1150 config_dir=config_dir, 

1151 include_depth=include_depth, 

1152 max_include_depth=max_include_depth, 

1153 file_opener=file_opener, 

1154 condition_matchers=condition_matchers, 

1155 ) 

1156 

1157 def _process_include( 

1158 self, 

1159 section: Section, 

1160 path_value: bytes, 

1161 *, 

1162 config_dir: str | None, 

1163 include_depth: int, 

1164 max_include_depth: int, 

1165 file_opener: FileOpener | None, 

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

1167 ) -> None: 

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

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

1170 

1171 # Handle includeIf conditions 

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

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

1174 if not self._evaluate_includeif_condition( 

1175 condition, config_dir, condition_matchers 

1176 ): 

1177 return 

1178 

1179 # Resolve the include path 

1180 include_path = self._resolve_include_path(path_str, config_dir) 

1181 if not include_path: 

1182 return 

1183 

1184 # Check for circular includes 

1185 try: 

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

1187 except (OSError, ValueError) as e: 

1188 # Invalid path - log and skip 

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

1190 return 

1191 if abs_path in self._included_paths: 

1192 return 

1193 

1194 # Load and merge the included file 

1195 try: 

1196 # Use provided file opener or default to GitFile 

1197 opener: FileOpener = ( 

1198 file_opener if file_opener is not None else lambda p: GitFile(p, "rb") 

1199 ) 

1200 

1201 f = opener(include_path) 

1202 except (OSError, ValueError) as e: 

1203 # Git silently ignores missing or unreadable include files 

1204 # Log for debugging purposes 

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

1206 else: 

1207 with f as included_file: 

1208 # Track this path to prevent cycles 

1209 self._included_paths.add(abs_path) 

1210 

1211 # Parse the included file 

1212 included_config = ConfigFile.from_file( 

1213 included_file, 

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

1215 included_paths=self._included_paths, 

1216 include_depth=include_depth + 1, 

1217 max_include_depth=max_include_depth, 

1218 file_opener=file_opener, 

1219 condition_matchers=condition_matchers, 

1220 ) 

1221 

1222 # Merge the included configuration 

1223 self._merge_config(included_config) 

1224 

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

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

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

1228 if section not in self._values: 

1229 self._values[section] = CaseInsensitiveOrderedMultiDict() 

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

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

1232 

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

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

1235 # Expand ~ to home directory 

1236 path = os.path.expanduser(path) 

1237 

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

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

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

1241 

1242 return path 

1243 

1244 def _evaluate_includeif_condition( 

1245 self, 

1246 condition: str, 

1247 config_dir: str | None = None, 

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

1249 ) -> bool: 

1250 """Evaluate an includeIf condition.""" 

1251 # Try custom matchers first if provided 

1252 if condition_matchers: 

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

1254 if condition.startswith(prefix): 

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

1256 

1257 # Fall back to built-in matchers 

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

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

1260 else: 

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

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

1263 return False 

1264 

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

1266 """Evaluate a hasconfig condition. 

1267 

1268 Format: hasconfig:config.key:pattern 

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

1270 """ 

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

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

1273 if len(parts) != 2: 

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

1275 return False 

1276 

1277 config_key, pattern = parts 

1278 

1279 # Parse the config key to get section and name 

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

1281 if len(key_parts) < 2: 

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

1283 return False 

1284 

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

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

1287 # Match any subsection 

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

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

1290 

1291 # Check all sections that match the pattern 

1292 for section in self.sections(): 

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

1294 try: 

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

1296 for value in values: 

1297 if self._match_hasconfig_pattern(value, pattern): 

1298 return True 

1299 except KeyError: 

1300 continue 

1301 else: 

1302 # Direct section lookup 

1303 if len(key_parts) == 2: 

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

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

1306 else: 

1307 section = ( 

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

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

1310 ) 

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

1312 

1313 try: 

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

1315 for value in values: 

1316 if self._match_hasconfig_pattern(value, pattern): 

1317 return True 

1318 except KeyError: 

1319 pass 

1320 

1321 return False 

1322 

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

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

1325 

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

1327 """ 

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

1329 return match_glob_pattern(value_str, pattern) 

1330 

1331 @classmethod 

1332 def from_path( 

1333 cls, 

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

1335 *, 

1336 max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH, 

1337 file_opener: FileOpener | None = None, 

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

1339 expand_includes: bool = True, 

1340 ) -> "ConfigFile": 

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

1342 

1343 Args: 

1344 path: Path to the configuration file 

1345 max_include_depth: Maximum allowed include depth 

1346 file_opener: Optional callback to open included files 

1347 condition_matchers: Optional dict of condition matchers for includeIf 

1348 expand_includes: Whether to honor include/includeIf directives. Set 

1349 to False when parsing untrusted config (e.g. .gitmodules) to 

1350 avoid following include.path to arbitrary files. 

1351 """ 

1352 abs_path = os.fspath(path) 

1353 config_dir = os.path.dirname(abs_path) 

1354 

1355 # Use provided file opener or default to GitFile 

1356 opener: FileOpener = ( 

1357 file_opener if file_opener is not None else lambda p: GitFile(p, "rb") 

1358 ) 

1359 

1360 with opener(abs_path) as f: 

1361 ret = cls.from_file( 

1362 f, 

1363 config_dir=config_dir, 

1364 max_include_depth=max_include_depth, 

1365 file_opener=file_opener, 

1366 condition_matchers=condition_matchers, 

1367 expand_includes=expand_includes, 

1368 ) 

1369 ret.path = abs_path 

1370 return ret 

1371 

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

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

1374 if path is None: 

1375 if self.path is None: 

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

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

1378 else: 

1379 path_to_use = path 

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

1381 self.write_to_file(f) 

1382 

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

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

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

1386 try: 

1387 section_name, subsection_name = section 

1388 except ValueError: 

1389 (section_name,) = section 

1390 subsection_name = None 

1391 if subsection_name is None: 

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

1393 else: 

1394 f.write( 

1395 b"[" 

1396 + section_name 

1397 + b' "' 

1398 + _escape_subsection(subsection_name) 

1399 + b'"]\n' 

1400 ) 

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

1402 value = _format_string(value) 

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

1404 

1405 

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

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

1408 

1409 Args: 

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

1411 

1412 Returns: 

1413 Full path in XDG config home directory 

1414 """ 

1415 xdg_config_home = os.environ.get( 

1416 "XDG_CONFIG_HOME", 

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

1418 ) 

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

1420 

1421 

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

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

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

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

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

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

1428 # 

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

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

1431 yield git_dir 

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

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

1434 yield parent_dir 

1435 break 

1436 

1437 

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

1439 import platform 

1440 import winreg 

1441 

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

1443 subkey = ( 

1444 "SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\" 

1445 "CurrentVersion\\Uninstall\\Git_is1" 

1446 ) 

1447 else: 

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

1449 

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

1451 with suppress(OSError): 

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

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

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

1455 yield val 

1456 

1457 

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

1459# following: 

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

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

1462# system registry 

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

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

1465 

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

1467 """ 

1468 # Try to find Git installation from PATH first 

1469 for git_dir in _find_git_in_win_path(): 

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

1471 return # Only use the first found path 

1472 

1473 # Fall back to registry if not found in PATH 

1474 for git_dir in _find_git_in_win_reg(): 

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

1476 return # Only use the first found path 

1477 

1478 

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

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

1481 

1482 Returns all possible config paths including deprecated locations. 

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

1484 """ 

1485 # Include deprecated PROGRAMDATA location 

1486 if "PROGRAMDATA" in os.environ: 

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

1488 

1489 # Include all Git installations found 

1490 for git_dir in _find_git_in_win_path(): 

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

1492 for git_dir in _find_git_in_win_reg(): 

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

1494 

1495 

1496def env_config( 

1497 environ: Mapping[str, str], 

1498) -> "ConfigFile | None": 

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

1500 

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

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

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

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

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

1506 

1507 Args: 

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

1509 

1510 Returns: 

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

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

1513 """ 

1514 raw_count = environ.get("GIT_CONFIG_COUNT") 

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

1516 return None 

1517 try: 

1518 count = int(raw_count) 

1519 except ValueError: 

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

1521 if count < 0: 

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

1523 

1524 cf = ConfigFile() 

1525 for i in range(count): 

1526 key_var = f"GIT_CONFIG_KEY_{i}" 

1527 value_var = f"GIT_CONFIG_VALUE_{i}" 

1528 try: 

1529 key = environ[key_var] 

1530 except KeyError: 

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

1532 try: 

1533 value = environ[value_var] 

1534 except KeyError: 

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

1536 if "." not in key: 

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

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

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

1540 first_dot = key.find(".") 

1541 last_dot = key.rfind(".") 

1542 section_name = key[:first_dot] 

1543 name = key[last_dot + 1 :] 

1544 if first_dot == last_dot: 

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

1546 else: 

1547 subsection = key[first_dot + 1 : last_dot] 

1548 section = ( 

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

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

1551 ) 

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

1553 return cf 

1554 

1555 

1556class StackedConfig(Config): 

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

1558 

1559 def __init__( 

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

1561 ) -> None: 

1562 """Initialize a StackedConfig. 

1563 

1564 Args: 

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

1566 writable: Optional config file to write changes to 

1567 """ 

1568 self.backends = backends 

1569 self.writable = writable 

1570 

1571 def __repr__(self) -> str: 

1572 """Return string representation of StackedConfig.""" 

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

1574 

1575 @classmethod 

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

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

1578 

1579 Returns: 

1580 StackedConfig with default configuration files loaded 

1581 """ 

1582 return cls(cls.default_backends()) 

1583 

1584 @classmethod 

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

1586 """Retrieve the default configuration. 

1587 

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

1589 """ 

1590 paths = [] 

1591 

1592 # Handle GIT_CONFIG_GLOBAL - overrides user config paths 

1593 try: 

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

1595 except KeyError: 

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

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

1598 

1599 # Handle GIT_CONFIG_SYSTEM and GIT_CONFIG_NOSYSTEM 

1600 try: 

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

1602 except KeyError: 

1603 if "GIT_CONFIG_NOSYSTEM" not in os.environ: 

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

1605 if sys.platform == "win32": 

1606 paths.extend(get_win_system_paths()) 

1607 

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

1609 

1610 backends = [] 

1611 for path in paths: 

1612 try: 

1613 cf = ConfigFile.from_path(path) 

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

1615 except FileNotFoundError: 

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

1617 continue 

1618 backends.append(cf) 

1619 

1620 return backends 

1621 

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

1623 """Get value from configuration.""" 

1624 if not isinstance(section, tuple): 

1625 section = (section,) 

1626 for backend in self.backends: 

1627 try: 

1628 return backend.get(section, name) 

1629 except KeyError: 

1630 pass 

1631 raise KeyError(name) 

1632 

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

1634 """Get multiple values from configuration.""" 

1635 if not isinstance(section, tuple): 

1636 section = (section,) 

1637 for backend in self.backends: 

1638 try: 

1639 yield from backend.get_multivar(section, name) 

1640 except KeyError: 

1641 pass 

1642 

1643 def set( 

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

1645 ) -> None: 

1646 """Set value in configuration.""" 

1647 if self.writable is None: 

1648 raise NotImplementedError(self.set) 

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

1650 

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

1652 """Get all sections.""" 

1653 seen = set() 

1654 for backend in self.backends: 

1655 for section in backend.sections(): 

1656 if section not in seen: 

1657 seen.add(section) 

1658 yield section 

1659 

1660 

1661def read_submodules( 

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

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

1664 """Read a .gitmodules file.""" 

1665 # .gitmodules is attacker-controlled in any cloned tree; do not expand 

1666 # include/includeIf directives so a hostile file cannot make us open and 

1667 # parse arbitrary paths on disk. This matches git, which parses submodule 

1668 # config with includes disabled. 

1669 cfg = ConfigFile.from_path(path, expand_includes=False) 

1670 return parse_submodules(cfg) 

1671 

1672 

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

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

1675 

1676 Args: 

1677 config: A `ConfigFile` 

1678 Returns: 

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

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

1681 """ 

1682 for section in config.sections(): 

1683 if len(section) != 2: 

1684 # Sections without a subsection name (e.g. a stray [include]) 

1685 # cannot be submodule entries; skip rather than crash. 

1686 continue 

1687 section_kind, section_name = section 

1688 if section_kind == b"submodule": 

1689 try: 

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

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

1692 yield (sm_path, sm_url, section_name) 

1693 except KeyError: 

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

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

1696 # how git itself handles malformed .gitmodule entries. 

1697 pass 

1698 

1699 

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

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

1702 for section in config.sections(): 

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

1704 continue 

1705 replacement = section[1] 

1706 try: 

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

1708 except KeyError: 

1709 needles = [] 

1710 if push: 

1711 try: 

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

1713 except KeyError: 

1714 pass 

1715 for needle in needles: 

1716 assert isinstance(needle, bytes) 

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

1718 

1719 

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

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

1722 

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

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

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

1726 catch-all default. 

1727 

1728 Args: 

1729 config: A Config instance. 

1730 host: The hostname being connected to. 

1731 

1732 Returns: 

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

1734 """ 

1735 try: 

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

1737 except KeyError: 

1738 return None 

1739 

1740 default_proxy: str | None = None 

1741 for raw in values: 

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

1743 # Entries may look like: 

1744 # proxy-command for kernel.org 

1745 # default-proxy 

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

1747 if len(parts) == 2: 

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

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

1750 return command 

1751 else: 

1752 default_proxy = text.strip() 

1753 

1754 return default_proxy 

1755 

1756 

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

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

1759 longest_needle = "" 

1760 updated_url = orig_url 

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

1762 if not orig_url.startswith(needle): 

1763 continue 

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

1765 longest_needle = needle 

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

1767 return updated_url