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 # Collapse a run of consecutive '*' to at most '**'. Repeated stars are 

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

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

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

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

161 # Convert glob pattern to regex 

162 pattern_escaped = re.escape(pattern) 

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

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

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

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

167 # Anchor the pattern 

168 pattern_regex = f"^{pattern_escaped}$" 

169 

170 try: 

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

172 except re.error as e: 

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

174 

175 

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

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

178 

179 Args: 

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

181 

182 Returns: 

183 Key with section names lowercased, subsection names preserved 

184 

185 Raises: 

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

187 """ 

188 if isinstance(key, bytes | str): 

189 return key.lower() 

190 

191 if isinstance(key, tuple): 

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

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

194 if len(key) > 0: 

195 first = key[0] 

196 assert isinstance(first, bytes | str) 

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

198 return key 

199 

200 raise TypeError(key) 

201 

202 

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

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

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

206 

207 

208class _UniqueKeysView(KeysView[K]): 

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

210 

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

212 self._keys = keys 

213 

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

215 return key in self._keys 

216 

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

218 return iter(self._keys) 

219 

220 def __len__(self) -> int: 

221 return len(self._keys) 

222 

223 

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

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

226 

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

228 self._mapping = mapping 

229 

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

231 return iter(self._mapping._real) 

232 

233 def __len__(self) -> int: 

234 return len(self._mapping._real) 

235 

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

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

238 return False 

239 key, value = item 

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

241 

242 

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

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

245 

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

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

248 """ 

249 

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

251 """Initialize a CaseInsensitiveOrderedMultiDict. 

252 

253 Args: 

254 default_factory: Optional factory function for default values 

255 """ 

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

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

258 self._default_factory = default_factory 

259 

260 @classmethod 

261 def make( 

262 cls, 

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

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

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

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

267 

268 Args: 

269 dict_in: Optional mapping to initialize from 

270 default_factory: Optional factory function for default values 

271 

272 Returns: 

273 New CaseInsensitiveOrderedMultiDict instance 

274 

275 Raises: 

276 TypeError: If dict_in is not a mapping or None 

277 """ 

278 if isinstance(dict_in, cls): 

279 return dict_in 

280 

281 out = cls(default_factory=default_factory) 

282 

283 if dict_in is None: 

284 return out 

285 

286 if not isinstance(dict_in, MutableMapping): 

287 raise TypeError 

288 

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

290 out[key] = value 

291 

292 return out 

293 

294 def __len__(self) -> int: 

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

296 return len(self._keyed) 

297 

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

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

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

301 # We need to deduplicate since _real can have duplicates 

302 seen = set() 

303 unique_keys = [] 

304 for k, _ in self._real: 

305 lower = lower_key(k) 

306 if lower not in seen: 

307 seen.add(lower) 

308 unique_keys.append(k) 

309 return _UniqueKeysView(unique_keys) 

310 

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

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

313 return _OrderedItemsView(self) 

314 

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

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

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

318 seen = set() 

319 for k, _ in self._real: 

320 lower = lower_key(k) 

321 if lower not in seen: 

322 seen.add(lower) 

323 yield k 

324 

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

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

327 return self._keyed.values() 

328 

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

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

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

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

333 

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

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

336 

337 Args: 

338 key: The key to set 

339 value: The value to set 

340 """ 

341 # This method replaces all existing values for the key 

342 lower = lower_key(key) 

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

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

345 self._keyed[lower] = value 

346 

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

348 """Delete all values for a key. 

349 

350 Raises: 

351 KeyError: If the key is not found 

352 """ 

353 lower_k = lower_key(key) 

354 del self._keyed[lower_k] 

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

356 if lower_key(actual) == lower_k: 

357 del self._real[i] 

358 

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

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

361 

362 Raises: 

363 KeyError: If the key is not found 

364 """ 

365 return self._keyed[lower_key(item)] 

366 

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

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

369 

370 Args: 

371 key: The key to look up 

372 default: Default value to return if key not found 

373 

374 Returns: 

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

376 """ 

377 try: 

378 return self[key] 

379 except KeyError: 

380 if default is not None: 

381 return default 

382 elif self._default_factory is not None: 

383 return self._default_factory() 

384 else: 

385 return None 

386 

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

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

389 

390 Args: 

391 key: The key to look up 

392 

393 Returns: 

394 Iterator of all values for the key 

395 """ 

396 lowered_key = lower_key(key) 

397 for actual, value in self._real: 

398 if lower_key(actual) == lowered_key: 

399 yield value 

400 

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

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

403 

404 Args: 

405 key: The key to look up 

406 default: Default value to set if key not found 

407 

408 Returns: 

409 The existing value or the newly set default 

410 

411 Raises: 

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

413 """ 

414 try: 

415 return self[key] 

416 except KeyError: 

417 if default is not None: 

418 self[key] = default 

419 return default 

420 elif self._default_factory is not None: 

421 value = self._default_factory() 

422 self[key] = value 

423 return value 

424 else: 

425 raise 

426 

427 

428Name = bytes 

429NameLike = bytes | str 

430Section = tuple[bytes, ...] 

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

432Value = bytes 

433ValueLike = bytes | str 

434 

435 

436class Config: 

437 """A Git configuration.""" 

438 

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

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

441 

442 Args: 

443 section: Tuple with section name and optional subsection name 

444 name: Variable name 

445 Returns: 

446 Contents of the setting 

447 Raises: 

448 KeyError: if the value is not set 

449 """ 

450 raise NotImplementedError(self.get) 

451 

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

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

454 

455 Args: 

456 section: Tuple with section name and optional subsection namee 

457 name: Variable name 

458 Returns: 

459 Contents of the setting as iterable 

460 Raises: 

461 KeyError: if the value is not set 

462 """ 

463 raise NotImplementedError(self.get_multivar) 

464 

465 @overload 

466 def get_boolean( 

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

468 ) -> bool: ... 

469 

470 @overload 

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

472 

473 def get_boolean( 

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

475 ) -> bool | None: 

476 """Retrieve a configuration setting as boolean. 

477 

478 Args: 

479 section: Tuple with section name and optional subsection name 

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

481 subsection. 

482 default: Default value if setting is not found 

483 

484 Returns: 

485 Contents of the setting 

486 """ 

487 try: 

488 value = self.get(section, name) 

489 except KeyError: 

490 return default 

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

492 return True 

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

494 return False 

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

496 

497 def set( 

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

499 ) -> None: 

500 """Set a configuration value. 

501 

502 Args: 

503 section: Tuple with section name and optional subsection namee 

504 name: Name of the configuration value, including section 

505 and optional subsection 

506 value: value of the setting 

507 """ 

508 raise NotImplementedError(self.set) 

509 

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

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

512 

513 Args: 

514 section: Tuple with section name and optional subsection namee 

515 Returns: 

516 Iterator over (name, value) pairs 

517 """ 

518 raise NotImplementedError(self.items) 

519 

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

521 """Iterate over the sections. 

522 

523 Returns: Iterator over section tuples 

524 """ 

525 raise NotImplementedError(self.sections) 

526 

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

528 """Check if a specified section exists. 

529 

530 Args: 

531 name: Name of section to check for 

532 Returns: 

533 boolean indicating whether the section exists 

534 """ 

535 return name in self.sections() 

536 

537 

538class ConfigDict(Config): 

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

540 

541 def __init__( 

542 self, 

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

544 | None = None, 

545 encoding: str | None = None, 

546 ) -> None: 

547 """Create a new ConfigDict.""" 

548 if encoding is None: 

549 encoding = sys.getdefaultencoding() 

550 self.encoding = encoding 

551 self._values: CaseInsensitiveOrderedMultiDict[ 

552 Section, CaseInsensitiveOrderedMultiDict[Name, Value] 

553 ] = CaseInsensitiveOrderedMultiDict.make( 

554 values, default_factory=CaseInsensitiveOrderedMultiDict 

555 ) 

556 

557 def __repr__(self) -> str: 

558 """Return string representation of ConfigDict.""" 

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

560 

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

562 """Check equality with another ConfigDict.""" 

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

564 

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

566 """Get configuration values for a section. 

567 

568 Raises: 

569 KeyError: If section not found 

570 """ 

571 return self._values.__getitem__(key) 

572 

573 def __setitem__( 

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

575 ) -> None: 

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

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

578 

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

580 """Delete a configuration section. 

581 

582 Raises: 

583 KeyError: If section not found 

584 """ 

585 return self._values.__delitem__(key) 

586 

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

588 """Iterate over configuration sections.""" 

589 return self._values.__iter__() 

590 

591 def __len__(self) -> int: 

592 """Return the number of sections.""" 

593 return self._values.__len__() 

594 

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

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

597 return self._values.keys() 

598 

599 @classmethod 

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

601 parts = name.split(".") 

602 if len(parts) == 3: 

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

604 else: 

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

606 

607 def _check_section_and_name( 

608 self, section: SectionLike, name: NameLike 

609 ) -> tuple[Section, Name]: 

610 if not isinstance(section, tuple): 

611 section = (section,) 

612 

613 checked_section = tuple( 

614 [ 

615 subsection.encode(self.encoding) 

616 if not isinstance(subsection, bytes) 

617 else subsection 

618 for subsection in section 

619 ] 

620 ) 

621 

622 if not isinstance(name, bytes): 

623 name = name.encode(self.encoding) 

624 

625 return checked_section, name 

626 

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

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

629 

630 Args: 

631 section: Section name 

632 name: Setting name 

633 

634 Returns: 

635 Iterator of configuration values 

636 """ 

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

638 assert len(section) >= 1 

639 

640 if len(section) > 1: 

641 try: 

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

643 except KeyError: 

644 pass 

645 

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

647 

648 def get( 

649 self, 

650 section: SectionLike, 

651 name: NameLike, 

652 ) -> Value: 

653 """Get a configuration value. 

654 

655 Args: 

656 section: Section name 

657 name: Setting name 

658 

659 Returns: 

660 Configuration value 

661 

662 Raises: 

663 KeyError: if the value is not set 

664 """ 

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

666 assert len(section) >= 1 

667 

668 if len(section) > 1: 

669 try: 

670 return self._values[section][name] 

671 except KeyError: 

672 pass 

673 

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

675 

676 def set( 

677 self, 

678 section: SectionLike, 

679 name: NameLike, 

680 value: ValueLike | bool, 

681 ) -> None: 

682 """Set a configuration value. 

683 

684 Args: 

685 section: Section name 

686 name: Setting name 

687 value: Configuration value 

688 """ 

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

690 

691 if isinstance(value, bool): 

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

693 

694 if not isinstance(value, bytes): 

695 value = value.encode(self.encoding) 

696 

697 section_dict = self._values.setdefault(section) 

698 if hasattr(section_dict, "set"): 

699 section_dict.set(name, value) 

700 else: 

701 section_dict[name] = value 

702 

703 def add( 

704 self, 

705 section: SectionLike, 

706 name: NameLike, 

707 value: ValueLike | bool, 

708 ) -> None: 

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

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

711 

712 if isinstance(value, bool): 

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

714 

715 if not isinstance(value, bytes): 

716 value = value.encode(self.encoding) 

717 

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

719 

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

721 """Remove a configuration setting. 

722 

723 Args: 

724 section: Section name 

725 name: Setting name 

726 

727 Raises: 

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

729 """ 

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

731 del self._values[section][name] 

732 

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

734 """Get items in a section.""" 

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

736 section_dict = self._values.get(section_bytes) 

737 if section_dict is not None: 

738 return iter(section_dict.items()) 

739 return iter([]) 

740 

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

742 """Get all sections.""" 

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

744 

745 

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

747 if ( 

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

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

750 or b"#" in value 

751 ): 

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

753 else: 

754 return _escape_value(value) 

755 

756 

757_ESCAPE_TABLE = { 

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

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

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

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

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

763} 

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

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

766 

767 

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

769 value_array = bytearray(value.strip()) 

770 ret = bytearray() 

771 whitespace = bytearray() 

772 in_quotes = False 

773 i = 0 

774 while i < len(value_array): 

775 c = value_array[i] 

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

777 i += 1 

778 if i >= len(value_array): 

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

780 if whitespace: 

781 ret.extend(whitespace) 

782 whitespace = bytearray() 

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

784 else: 

785 try: 

786 v = _ESCAPE_TABLE[value_array[i]] 

787 if whitespace: 

788 ret.extend(whitespace) 

789 whitespace = bytearray() 

790 ret.append(v) 

791 except KeyError: 

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

793 if whitespace: 

794 ret.extend(whitespace) 

795 whitespace = bytearray() 

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

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

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

799 in_quotes = not in_quotes 

800 elif c in _COMMENT_CHARS and not in_quotes: 

801 # the rest of the line is a comment 

802 break 

803 elif c in _WHITESPACE_CHARS: 

804 if in_quotes: 

805 ret.append(c) 

806 else: 

807 whitespace.append(c) 

808 else: 

809 if whitespace: 

810 ret.extend(whitespace) 

811 whitespace = bytearray() 

812 ret.append(c) 

813 i += 1 

814 

815 if in_quotes: 

816 raise ValueError("missing end quote") 

817 

818 return bytes(ret) 

819 

820 

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

822 """Escape a value.""" 

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

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

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

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

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

828 return value 

829 

830 

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

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

833 

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

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

836 """ 

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

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

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

840 

841 

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

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

844 

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

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

847 match that lenient behaviour to stay compatible with config files 

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

849 Windows paths where backslashes were not doubled). 

850 """ 

851 out = bytearray() 

852 i = 0 

853 while i < len(name): 

854 c = name[i : i + 1] 

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

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

857 i += 2 

858 else: 

859 out += c 

860 i += 1 

861 return bytes(out) 

862 

863 

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

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

866 c = name[i : i + 1] 

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

868 return False 

869 return True 

870 

871 

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

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

874 c = name[i : i + 1] 

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

876 return False 

877 return True 

878 

879 

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

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

882 quote = ord(b'"') 

883 string_open = False 

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

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

886 # Comment characters outside balanced quotes denote comment start 

887 if character == quote: 

888 string_open = not string_open 

889 elif not string_open and character in comment_bytes: 

890 return line[:i] 

891 return line 

892 

893 

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

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

896 

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

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

899 2. Not within quotes 

900 

901 Args: 

902 value: The value to check 

903 

904 Returns: 

905 True if the value ends with a line continuation backslash 

906 """ 

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

908 return False 

909 

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

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

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

913 else: 

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

915 

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

917 return False 

918 

919 # Count consecutive backslashes at the end 

920 backslash_count = 0 

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

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

923 backslash_count += 1 

924 else: 

925 break 

926 

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

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

929 return backslash_count % 2 == 1 

930 

931 

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

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

934 line = _strip_comments(line).rstrip() 

935 in_quotes = False 

936 escaped = False 

937 for i, c in enumerate(line): 

938 if escaped: 

939 escaped = False 

940 continue 

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

942 in_quotes = not in_quotes 

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

944 escaped = True 

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

946 last = i 

947 break 

948 else: 

949 raise ValueError("expected trailing ]") 

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

951 line = line[last + 1 :] 

952 section: Section 

953 if len(pts) == 2: 

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

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

956 # Standard quoted subsection 

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

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

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

960 # Git allows these without strict quote validation 

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

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

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

964 else: 

965 # Other sections must have quoted subsections 

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

967 if not _check_section_name(pts[0]): 

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

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

970 else: 

971 if not _check_section_name(pts[0]): 

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

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

974 if len(pts) == 2: 

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

976 else: 

977 section = (pts[0],) 

978 return section, line 

979 

980 

981class ConfigFile(ConfigDict): 

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

983 

984 def __init__( 

985 self, 

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

987 | None = None, 

988 encoding: str | None = None, 

989 ) -> None: 

990 """Initialize a ConfigFile. 

991 

992 Args: 

993 values: Optional mapping of configuration values 

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

995 """ 

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

997 self.path: str | None = None 

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

999 

1000 @classmethod 

1001 def from_file( 

1002 cls, 

1003 f: IO[bytes], 

1004 *, 

1005 config_dir: str | None = None, 

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

1007 include_depth: int = 0, 

1008 max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH, 

1009 file_opener: FileOpener | None = None, 

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

1011 expand_includes: bool = True, 

1012 ) -> "ConfigFile": 

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

1014 

1015 Args: 

1016 f: File-like object to read from 

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

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

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

1020 max_include_depth: Maximum allowed include depth 

1021 file_opener: Optional callback to open included files 

1022 condition_matchers: Optional dict of condition matchers for includeIf 

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

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

1025 avoid following include.path to arbitrary files. 

1026 """ 

1027 if include_depth > max_include_depth: 

1028 # Prevent excessive recursion 

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

1030 

1031 ret = cls() 

1032 if included_paths is not None: 

1033 ret._included_paths = included_paths.copy() 

1034 

1035 section: Section | None = None 

1036 setting = None 

1037 continuation = None 

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

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

1040 line = line[3:] 

1041 line = line.lstrip() 

1042 if setting is None: 

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

1044 section, line = _parse_section_header_line(line) 

1045 ret._values.setdefault(section) 

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

1047 continue 

1048 if section is None: 

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

1050 try: 

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

1052 except ValueError: 

1053 setting = line 

1054 value = b"true" 

1055 setting = setting.strip() 

1056 if not _check_variable_name(setting): 

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

1058 if _is_line_continuation(value): 

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

1060 continuation = value[:-3] 

1061 else: 

1062 continuation = value[:-2] 

1063 else: 

1064 continuation = None 

1065 value = _parse_string(value) 

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

1067 

1068 # Process include/includeIf directives 

1069 if expand_includes: 

1070 ret._handle_include_directive( 

1071 section, 

1072 setting, 

1073 value, 

1074 config_dir=config_dir, 

1075 include_depth=include_depth, 

1076 max_include_depth=max_include_depth, 

1077 file_opener=file_opener, 

1078 condition_matchers=condition_matchers, 

1079 ) 

1080 

1081 setting = None 

1082 else: # continuation line 

1083 assert continuation is not None 

1084 if _is_line_continuation(line): 

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

1086 continuation += line[:-3] 

1087 else: 

1088 continuation += line[:-2] 

1089 else: 

1090 continuation += line 

1091 value = _parse_string(continuation) 

1092 assert section is not None # Already checked above 

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

1094 

1095 # Process include/includeIf directives 

1096 if expand_includes: 

1097 ret._handle_include_directive( 

1098 section, 

1099 setting, 

1100 value, 

1101 config_dir=config_dir, 

1102 include_depth=include_depth, 

1103 max_include_depth=max_include_depth, 

1104 file_opener=file_opener, 

1105 condition_matchers=condition_matchers, 

1106 ) 

1107 

1108 continuation = None 

1109 setting = None 

1110 return ret 

1111 

1112 def _handle_include_directive( 

1113 self, 

1114 section: Section | None, 

1115 setting: bytes, 

1116 value: bytes, 

1117 *, 

1118 config_dir: str | None, 

1119 include_depth: int, 

1120 max_include_depth: int, 

1121 file_opener: FileOpener | None, 

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

1123 ) -> None: 

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

1125 if ( 

1126 section is not None 

1127 and setting == b"path" 

1128 and ( 

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

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

1131 ) 

1132 ): 

1133 self._process_include( 

1134 section, 

1135 value, 

1136 config_dir=config_dir, 

1137 include_depth=include_depth, 

1138 max_include_depth=max_include_depth, 

1139 file_opener=file_opener, 

1140 condition_matchers=condition_matchers, 

1141 ) 

1142 

1143 def _process_include( 

1144 self, 

1145 section: Section, 

1146 path_value: bytes, 

1147 *, 

1148 config_dir: str | None, 

1149 include_depth: int, 

1150 max_include_depth: int, 

1151 file_opener: FileOpener | None, 

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

1153 ) -> None: 

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

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

1156 

1157 # Handle includeIf conditions 

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

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

1160 if not self._evaluate_includeif_condition( 

1161 condition, config_dir, condition_matchers 

1162 ): 

1163 return 

1164 

1165 # Resolve the include path 

1166 include_path = self._resolve_include_path(path_str, config_dir) 

1167 if not include_path: 

1168 return 

1169 

1170 # Check for circular includes 

1171 try: 

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

1173 except (OSError, ValueError) as e: 

1174 # Invalid path - log and skip 

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

1176 return 

1177 if abs_path in self._included_paths: 

1178 return 

1179 

1180 # Load and merge the included file 

1181 try: 

1182 # Use provided file opener or default to GitFile 

1183 opener: FileOpener = ( 

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

1185 ) 

1186 

1187 f = opener(include_path) 

1188 except (OSError, ValueError) as e: 

1189 # Git silently ignores missing or unreadable include files 

1190 # Log for debugging purposes 

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

1192 else: 

1193 with f as included_file: 

1194 # Track this path to prevent cycles 

1195 self._included_paths.add(abs_path) 

1196 

1197 # Parse the included file 

1198 included_config = ConfigFile.from_file( 

1199 included_file, 

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

1201 included_paths=self._included_paths, 

1202 include_depth=include_depth + 1, 

1203 max_include_depth=max_include_depth, 

1204 file_opener=file_opener, 

1205 condition_matchers=condition_matchers, 

1206 ) 

1207 

1208 # Merge the included configuration 

1209 self._merge_config(included_config) 

1210 

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

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

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

1214 if section not in self._values: 

1215 self._values[section] = CaseInsensitiveOrderedMultiDict() 

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

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

1218 

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

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

1221 # Expand ~ to home directory 

1222 path = os.path.expanduser(path) 

1223 

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

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

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

1227 

1228 return path 

1229 

1230 def _evaluate_includeif_condition( 

1231 self, 

1232 condition: str, 

1233 config_dir: str | None = None, 

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

1235 ) -> bool: 

1236 """Evaluate an includeIf condition.""" 

1237 # Try custom matchers first if provided 

1238 if condition_matchers: 

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

1240 if condition.startswith(prefix): 

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

1242 

1243 # Fall back to built-in matchers 

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

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

1246 else: 

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

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

1249 return False 

1250 

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

1252 """Evaluate a hasconfig condition. 

1253 

1254 Format: hasconfig:config.key:pattern 

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

1256 """ 

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

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

1259 if len(parts) != 2: 

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

1261 return False 

1262 

1263 config_key, pattern = parts 

1264 

1265 # Parse the config key to get section and name 

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

1267 if len(key_parts) < 2: 

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

1269 return False 

1270 

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

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

1273 # Match any subsection 

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

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

1276 

1277 # Check all sections that match the pattern 

1278 for section in self.sections(): 

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

1280 try: 

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

1282 for value in values: 

1283 if self._match_hasconfig_pattern(value, pattern): 

1284 return True 

1285 except KeyError: 

1286 continue 

1287 else: 

1288 # Direct section lookup 

1289 if len(key_parts) == 2: 

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

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

1292 else: 

1293 section = ( 

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

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

1296 ) 

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

1298 

1299 try: 

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

1301 for value in values: 

1302 if self._match_hasconfig_pattern(value, pattern): 

1303 return True 

1304 except KeyError: 

1305 pass 

1306 

1307 return False 

1308 

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

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

1311 

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

1313 """ 

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

1315 return match_glob_pattern(value_str, pattern) 

1316 

1317 @classmethod 

1318 def from_path( 

1319 cls, 

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

1321 *, 

1322 max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH, 

1323 file_opener: FileOpener | None = None, 

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

1325 expand_includes: bool = True, 

1326 ) -> "ConfigFile": 

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

1328 

1329 Args: 

1330 path: Path to the configuration file 

1331 max_include_depth: Maximum allowed include depth 

1332 file_opener: Optional callback to open included files 

1333 condition_matchers: Optional dict of condition matchers for includeIf 

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

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

1336 avoid following include.path to arbitrary files. 

1337 """ 

1338 abs_path = os.fspath(path) 

1339 config_dir = os.path.dirname(abs_path) 

1340 

1341 # Use provided file opener or default to GitFile 

1342 opener: FileOpener = ( 

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

1344 ) 

1345 

1346 with opener(abs_path) as f: 

1347 ret = cls.from_file( 

1348 f, 

1349 config_dir=config_dir, 

1350 max_include_depth=max_include_depth, 

1351 file_opener=file_opener, 

1352 condition_matchers=condition_matchers, 

1353 expand_includes=expand_includes, 

1354 ) 

1355 ret.path = abs_path 

1356 return ret 

1357 

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

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

1360 if path is None: 

1361 if self.path is None: 

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

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

1364 else: 

1365 path_to_use = path 

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

1367 self.write_to_file(f) 

1368 

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

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

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

1372 try: 

1373 section_name, subsection_name = section 

1374 except ValueError: 

1375 (section_name,) = section 

1376 subsection_name = None 

1377 if subsection_name is None: 

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

1379 else: 

1380 f.write( 

1381 b"[" 

1382 + section_name 

1383 + b' "' 

1384 + _escape_subsection(subsection_name) 

1385 + b'"]\n' 

1386 ) 

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

1388 value = _format_string(value) 

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

1390 

1391 

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

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

1394 

1395 Args: 

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

1397 

1398 Returns: 

1399 Full path in XDG config home directory 

1400 """ 

1401 xdg_config_home = os.environ.get( 

1402 "XDG_CONFIG_HOME", 

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

1404 ) 

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

1406 

1407 

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

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

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

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

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

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

1414 # 

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

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

1417 yield git_dir 

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

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

1420 yield parent_dir 

1421 break 

1422 

1423 

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

1425 import platform 

1426 import winreg 

1427 

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

1429 subkey = ( 

1430 "SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\" 

1431 "CurrentVersion\\Uninstall\\Git_is1" 

1432 ) 

1433 else: 

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

1435 

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

1437 with suppress(OSError): 

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

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

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

1441 yield val 

1442 

1443 

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

1445# following: 

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

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

1448# system registry 

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

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

1451 

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

1453 """ 

1454 # Try to find Git installation from PATH first 

1455 for git_dir in _find_git_in_win_path(): 

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

1457 return # Only use the first found path 

1458 

1459 # Fall back to registry if not found in PATH 

1460 for git_dir in _find_git_in_win_reg(): 

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

1462 return # Only use the first found path 

1463 

1464 

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

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

1467 

1468 Returns all possible config paths including deprecated locations. 

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

1470 """ 

1471 # Include deprecated PROGRAMDATA location 

1472 if "PROGRAMDATA" in os.environ: 

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

1474 

1475 # Include all Git installations found 

1476 for git_dir in _find_git_in_win_path(): 

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

1478 for git_dir in _find_git_in_win_reg(): 

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

1480 

1481 

1482def env_config( 

1483 environ: Mapping[str, str], 

1484) -> "ConfigFile | None": 

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

1486 

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

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

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

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

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

1492 

1493 Args: 

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

1495 

1496 Returns: 

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

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

1499 """ 

1500 raw_count = environ.get("GIT_CONFIG_COUNT") 

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

1502 return None 

1503 try: 

1504 count = int(raw_count) 

1505 except ValueError: 

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

1507 if count < 0: 

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

1509 

1510 cf = ConfigFile() 

1511 for i in range(count): 

1512 key_var = f"GIT_CONFIG_KEY_{i}" 

1513 value_var = f"GIT_CONFIG_VALUE_{i}" 

1514 try: 

1515 key = environ[key_var] 

1516 except KeyError: 

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

1518 try: 

1519 value = environ[value_var] 

1520 except KeyError: 

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

1522 if "." not in key: 

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

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

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

1526 first_dot = key.find(".") 

1527 last_dot = key.rfind(".") 

1528 section_name = key[:first_dot] 

1529 name = key[last_dot + 1 :] 

1530 if first_dot == last_dot: 

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

1532 else: 

1533 subsection = key[first_dot + 1 : last_dot] 

1534 section = ( 

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

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

1537 ) 

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

1539 return cf 

1540 

1541 

1542class StackedConfig(Config): 

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

1544 

1545 def __init__( 

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

1547 ) -> None: 

1548 """Initialize a StackedConfig. 

1549 

1550 Args: 

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

1552 writable: Optional config file to write changes to 

1553 """ 

1554 self.backends = backends 

1555 self.writable = writable 

1556 

1557 def __repr__(self) -> str: 

1558 """Return string representation of StackedConfig.""" 

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

1560 

1561 @classmethod 

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

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

1564 

1565 Returns: 

1566 StackedConfig with default configuration files loaded 

1567 """ 

1568 return cls(cls.default_backends()) 

1569 

1570 @classmethod 

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

1572 """Retrieve the default configuration. 

1573 

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

1575 """ 

1576 paths = [] 

1577 

1578 # Handle GIT_CONFIG_GLOBAL - overrides user config paths 

1579 try: 

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

1581 except KeyError: 

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

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

1584 

1585 # Handle GIT_CONFIG_SYSTEM and GIT_CONFIG_NOSYSTEM 

1586 try: 

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

1588 except KeyError: 

1589 if "GIT_CONFIG_NOSYSTEM" not in os.environ: 

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

1591 if sys.platform == "win32": 

1592 paths.extend(get_win_system_paths()) 

1593 

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

1595 

1596 backends = [] 

1597 for path in paths: 

1598 try: 

1599 cf = ConfigFile.from_path(path) 

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

1601 except FileNotFoundError: 

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

1603 continue 

1604 backends.append(cf) 

1605 

1606 return backends 

1607 

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

1609 """Get value from configuration.""" 

1610 if not isinstance(section, tuple): 

1611 section = (section,) 

1612 for backend in self.backends: 

1613 try: 

1614 return backend.get(section, name) 

1615 except KeyError: 

1616 pass 

1617 raise KeyError(name) 

1618 

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

1620 """Get multiple values from configuration.""" 

1621 if not isinstance(section, tuple): 

1622 section = (section,) 

1623 for backend in self.backends: 

1624 try: 

1625 yield from backend.get_multivar(section, name) 

1626 except KeyError: 

1627 pass 

1628 

1629 def set( 

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

1631 ) -> None: 

1632 """Set value in configuration.""" 

1633 if self.writable is None: 

1634 raise NotImplementedError(self.set) 

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

1636 

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

1638 """Get all sections.""" 

1639 seen = set() 

1640 for backend in self.backends: 

1641 for section in backend.sections(): 

1642 if section not in seen: 

1643 seen.add(section) 

1644 yield section 

1645 

1646 

1647def read_submodules( 

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

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

1650 """Read a .gitmodules file.""" 

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

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

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

1654 # config with includes disabled. 

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

1656 return parse_submodules(cfg) 

1657 

1658 

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

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

1661 

1662 Args: 

1663 config: A `ConfigFile` 

1664 Returns: 

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

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

1667 """ 

1668 for section in config.sections(): 

1669 if len(section) != 2: 

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

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

1672 continue 

1673 section_kind, section_name = section 

1674 if section_kind == b"submodule": 

1675 try: 

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

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

1678 yield (sm_path, sm_url, section_name) 

1679 except KeyError: 

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

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

1682 # how git itself handles malformed .gitmodule entries. 

1683 pass 

1684 

1685 

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

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

1688 for section in config.sections(): 

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

1690 continue 

1691 replacement = section[1] 

1692 try: 

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

1694 except KeyError: 

1695 needles = [] 

1696 if push: 

1697 try: 

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

1699 except KeyError: 

1700 pass 

1701 for needle in needles: 

1702 assert isinstance(needle, bytes) 

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

1704 

1705 

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

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

1708 

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

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

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

1712 catch-all default. 

1713 

1714 Args: 

1715 config: A Config instance. 

1716 host: The hostname being connected to. 

1717 

1718 Returns: 

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

1720 """ 

1721 try: 

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

1723 except KeyError: 

1724 return None 

1725 

1726 default_proxy: str | None = None 

1727 for raw in values: 

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

1729 # Entries may look like: 

1730 # proxy-command for kernel.org 

1731 # default-proxy 

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

1733 if len(parts) == 2: 

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

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

1736 return command 

1737 else: 

1738 default_proxy = text.strip() 

1739 

1740 return default_proxy 

1741 

1742 

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

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

1745 longest_needle = "" 

1746 updated_url = orig_url 

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

1748 if not orig_url.startswith(needle): 

1749 continue 

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

1751 longest_needle = needle 

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

1753 return updated_url