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

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

731 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 "get_win_legacy_system_paths", 

42 "get_win_system_paths", 

43 "get_xdg_config_home_path", 

44 "iter_instead_of", 

45 "lower_key", 

46 "match_glob_pattern", 

47 "parse_submodules", 

48 "read_submodules", 

49] 

50 

51import logging 

52import os 

53import re 

54import sys 

55from collections.abc import ( 

56 Callable, 

57 ItemsView, 

58 Iterable, 

59 Iterator, 

60 KeysView, 

61 Mapping, 

62 MutableMapping, 

63 ValuesView, 

64) 

65from contextlib import suppress 

66from pathlib import Path 

67from typing import ( 

68 IO, 

69 Generic, 

70 TypeVar, 

71 overload, 

72) 

73 

74from .file import GitFile, _GitFile 

75 

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

77ConfigValue = str | bytes | bool | int 

78 

79logger = logging.getLogger(__name__) 

80 

81# Type for file opener callback 

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

83 

84# Type for includeIf condition matcher 

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

86ConditionMatcher = Callable[[str], bool] 

87 

88# Security limits for include files 

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

90DEFAULT_MAX_INCLUDE_DEPTH = 10 # Maximum recursion depth for includes 

91 

92 

93def _match_gitdir_pattern( 

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

95) -> bool: 

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

97 

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

99 """ 

100 # Convert to strings for easier manipulation 

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

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

103 

104 # Normalize paths to use forward slashes for consistent matching 

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

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

107 

108 if ignorecase: 

109 path_str = path_str.lower() 

110 pattern_str = pattern_str.lower() 

111 

112 # Handle the common cases for gitdir patterns 

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

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

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

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

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

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

119 # Pattern like **/filename 

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

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

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

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

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

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

126 elif "**" in pattern_str: 

127 # Handle patterns with ** in the middle 

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

129 if len(parts) == 2: 

130 prefix, suffix = parts 

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

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

133 return False 

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

135 return False 

136 return True 

137 

138 # Direct match or simple glob pattern 

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

140 import fnmatch 

141 

142 return fnmatch.fnmatch(path_str, pattern_str) 

143 else: 

144 return path_str == pattern_str 

145 

146 

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

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

149 

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

151 

152 Raises: 

153 ValueError: If the pattern is invalid 

154 """ 

155 # Convert glob pattern to regex 

156 pattern_escaped = re.escape(pattern) 

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

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

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

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

161 # Anchor the pattern 

162 pattern_regex = f"^{pattern_escaped}$" 

163 

164 try: 

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

166 except re.error as e: 

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

168 

169 

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

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

172 

173 Args: 

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

175 

176 Returns: 

177 Key with section names lowercased, subsection names preserved 

178 

179 Raises: 

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

181 """ 

182 if isinstance(key, (bytes, str)): 

183 return key.lower() 

184 

185 if isinstance(key, tuple): 

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

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

188 if len(key) > 0: 

189 first = key[0] 

190 assert isinstance(first, (bytes, str)) 

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

192 return key 

193 

194 raise TypeError(key) 

195 

196 

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

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

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

200 

201 

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

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

204 

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

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

207 """ 

208 

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

210 """Initialize a CaseInsensitiveOrderedMultiDict. 

211 

212 Args: 

213 default_factory: Optional factory function for default values 

214 """ 

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

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

217 self._default_factory = default_factory 

218 

219 @classmethod 

220 def make( 

221 cls, 

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

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

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

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

226 

227 Args: 

228 dict_in: Optional mapping to initialize from 

229 default_factory: Optional factory function for default values 

230 

231 Returns: 

232 New CaseInsensitiveOrderedMultiDict instance 

233 

234 Raises: 

235 TypeError: If dict_in is not a mapping or None 

236 """ 

237 if isinstance(dict_in, cls): 

238 return dict_in 

239 

240 out = cls(default_factory=default_factory) 

241 

242 if dict_in is None: 

243 return out 

244 

245 if not isinstance(dict_in, MutableMapping): 

246 raise TypeError 

247 

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

249 out[key] = value 

250 

251 return out 

252 

253 def __len__(self) -> int: 

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

255 return len(self._keyed) 

256 

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

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

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

260 # We need to deduplicate since _real can have duplicates 

261 seen = set() 

262 unique_keys = [] 

263 for k, _ in self._real: 

264 lower = lower_key(k) 

265 if lower not in seen: 

266 seen.add(lower) 

267 unique_keys.append(k) 

268 from collections.abc import KeysView as ABCKeysView 

269 

270 class UniqueKeysView(ABCKeysView[K]): 

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

272 self._keys = keys 

273 

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

275 return key in self._keys 

276 

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

278 return iter(self._keys) 

279 

280 def __len__(self) -> int: 

281 return len(self._keys) 

282 

283 return UniqueKeysView(unique_keys) 

284 

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

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

287 

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

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

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

291 

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

293 self._mapping = mapping 

294 

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

296 return iter(self._mapping._real) 

297 

298 def __len__(self) -> int: 

299 return len(self._mapping._real) 

300 

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

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

303 return False 

304 key, value = item 

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

306 

307 return OrderedItemsView(self) 

308 

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

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

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

312 seen = set() 

313 for k, _ in self._real: 

314 lower = lower_key(k) 

315 if lower not in seen: 

316 seen.add(lower) 

317 yield k 

318 

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

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

321 return self._keyed.values() 

322 

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

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

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

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

327 

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

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

330 

331 Args: 

332 key: The key to set 

333 value: The value to set 

334 """ 

335 # This method replaces all existing values for the key 

336 lower = lower_key(key) 

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

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

339 self._keyed[lower] = value 

340 

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

342 """Delete all values for a key. 

343 

344 Raises: 

345 KeyError: If the key is not found 

346 """ 

347 lower_k = lower_key(key) 

348 del self._keyed[lower_k] 

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

350 if lower_key(actual) == lower_k: 

351 del self._real[i] 

352 

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

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

355 

356 Raises: 

357 KeyError: If the key is not found 

358 """ 

359 return self._keyed[lower_key(item)] 

360 

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

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

363 

364 Args: 

365 key: The key to look up 

366 default: Default value to return if key not found 

367 

368 Returns: 

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

370 """ 

371 try: 

372 return self[key] 

373 except KeyError: 

374 if default is not None: 

375 return default 

376 elif self._default_factory is not None: 

377 return self._default_factory() 

378 else: 

379 return None 

380 

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

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

383 

384 Args: 

385 key: The key to look up 

386 

387 Returns: 

388 Iterator of all values for the key 

389 """ 

390 lowered_key = lower_key(key) 

391 for actual, value in self._real: 

392 if lower_key(actual) == lowered_key: 

393 yield value 

394 

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

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

397 

398 Args: 

399 key: The key to look up 

400 default: Default value to set if key not found 

401 

402 Returns: 

403 The existing value or the newly set default 

404 

405 Raises: 

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

407 """ 

408 try: 

409 return self[key] 

410 except KeyError: 

411 if default is not None: 

412 self[key] = default 

413 return default 

414 elif self._default_factory is not None: 

415 value = self._default_factory() 

416 self[key] = value 

417 return value 

418 else: 

419 raise 

420 

421 

422Name = bytes 

423NameLike = bytes | str 

424Section = tuple[bytes, ...] 

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

426Value = bytes 

427ValueLike = bytes | str 

428 

429 

430class Config: 

431 """A Git configuration.""" 

432 

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

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

435 

436 Args: 

437 section: Tuple with section name and optional subsection name 

438 name: Variable name 

439 Returns: 

440 Contents of the setting 

441 Raises: 

442 KeyError: if the value is not set 

443 """ 

444 raise NotImplementedError(self.get) 

445 

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

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

448 

449 Args: 

450 section: Tuple with section name and optional subsection namee 

451 name: Variable name 

452 Returns: 

453 Contents of the setting as iterable 

454 Raises: 

455 KeyError: if the value is not set 

456 """ 

457 raise NotImplementedError(self.get_multivar) 

458 

459 @overload 

460 def get_boolean( 

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

462 ) -> bool: ... 

463 

464 @overload 

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

466 

467 def get_boolean( 

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

469 ) -> bool | None: 

470 """Retrieve a configuration setting as boolean. 

471 

472 Args: 

473 section: Tuple with section name and optional subsection name 

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

475 subsection. 

476 default: Default value if setting is not found 

477 

478 Returns: 

479 Contents of the setting 

480 """ 

481 try: 

482 value = self.get(section, name) 

483 except KeyError: 

484 return default 

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

486 return True 

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

488 return False 

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

490 

491 def set( 

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

493 ) -> None: 

494 """Set a configuration value. 

495 

496 Args: 

497 section: Tuple with section name and optional subsection namee 

498 name: Name of the configuration value, including section 

499 and optional subsection 

500 value: value of the setting 

501 """ 

502 raise NotImplementedError(self.set) 

503 

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

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

506 

507 Args: 

508 section: Tuple with section name and optional subsection namee 

509 Returns: 

510 Iterator over (name, value) pairs 

511 """ 

512 raise NotImplementedError(self.items) 

513 

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

515 """Iterate over the sections. 

516 

517 Returns: Iterator over section tuples 

518 """ 

519 raise NotImplementedError(self.sections) 

520 

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

522 """Check if a specified section exists. 

523 

524 Args: 

525 name: Name of section to check for 

526 Returns: 

527 boolean indicating whether the section exists 

528 """ 

529 return name in self.sections() 

530 

531 

532class ConfigDict(Config): 

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

534 

535 def __init__( 

536 self, 

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

538 | None = None, 

539 encoding: str | None = None, 

540 ) -> None: 

541 """Create a new ConfigDict.""" 

542 if encoding is None: 

543 encoding = sys.getdefaultencoding() 

544 self.encoding = encoding 

545 self._values: CaseInsensitiveOrderedMultiDict[ 

546 Section, CaseInsensitiveOrderedMultiDict[Name, Value] 

547 ] = CaseInsensitiveOrderedMultiDict.make( 

548 values, default_factory=CaseInsensitiveOrderedMultiDict 

549 ) 

550 

551 def __repr__(self) -> str: 

552 """Return string representation of ConfigDict.""" 

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

554 

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

556 """Check equality with another ConfigDict.""" 

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

558 

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

560 """Get configuration values for a section. 

561 

562 Raises: 

563 KeyError: If section not found 

564 """ 

565 return self._values.__getitem__(key) 

566 

567 def __setitem__( 

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

569 ) -> None: 

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

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

572 

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

574 """Delete a configuration section. 

575 

576 Raises: 

577 KeyError: If section not found 

578 """ 

579 return self._values.__delitem__(key) 

580 

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

582 """Iterate over configuration sections.""" 

583 return self._values.__iter__() 

584 

585 def __len__(self) -> int: 

586 """Return the number of sections.""" 

587 return self._values.__len__() 

588 

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

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

591 return self._values.keys() 

592 

593 @classmethod 

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

595 parts = name.split(".") 

596 if len(parts) == 3: 

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

598 else: 

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

600 

601 def _check_section_and_name( 

602 self, section: SectionLike, name: NameLike 

603 ) -> tuple[Section, Name]: 

604 if not isinstance(section, tuple): 

605 section = (section,) 

606 

607 checked_section = tuple( 

608 [ 

609 subsection.encode(self.encoding) 

610 if not isinstance(subsection, bytes) 

611 else subsection 

612 for subsection in section 

613 ] 

614 ) 

615 

616 if not isinstance(name, bytes): 

617 name = name.encode(self.encoding) 

618 

619 return checked_section, name 

620 

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

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

623 

624 Args: 

625 section: Section name 

626 name: Setting name 

627 

628 Returns: 

629 Iterator of configuration values 

630 """ 

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

632 

633 if len(section) > 1: 

634 try: 

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

636 except KeyError: 

637 pass 

638 

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

640 

641 def get( 

642 self, 

643 section: SectionLike, 

644 name: NameLike, 

645 ) -> Value: 

646 """Get a configuration value. 

647 

648 Args: 

649 section: Section name 

650 name: Setting name 

651 

652 Returns: 

653 Configuration value 

654 

655 Raises: 

656 KeyError: if the value is not set 

657 """ 

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

659 

660 if len(section) > 1: 

661 try: 

662 return self._values[section][name] 

663 except KeyError: 

664 pass 

665 

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

667 

668 def set( 

669 self, 

670 section: SectionLike, 

671 name: NameLike, 

672 value: ValueLike | bool, 

673 ) -> None: 

674 """Set a configuration value. 

675 

676 Args: 

677 section: Section name 

678 name: Setting name 

679 value: Configuration value 

680 """ 

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

682 

683 if isinstance(value, bool): 

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

685 

686 if not isinstance(value, bytes): 

687 value = value.encode(self.encoding) 

688 

689 section_dict = self._values.setdefault(section) 

690 if hasattr(section_dict, "set"): 

691 section_dict.set(name, value) 

692 else: 

693 section_dict[name] = value 

694 

695 def add( 

696 self, 

697 section: SectionLike, 

698 name: NameLike, 

699 value: ValueLike | bool, 

700 ) -> None: 

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

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

703 

704 if isinstance(value, bool): 

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

706 

707 if not isinstance(value, bytes): 

708 value = value.encode(self.encoding) 

709 

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

711 

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

713 """Get items in a section.""" 

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

715 section_dict = self._values.get(section_bytes) 

716 if section_dict is not None: 

717 return iter(section_dict.items()) 

718 return iter([]) 

719 

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

721 """Get all sections.""" 

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

723 

724 

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

726 if ( 

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

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

729 or b"#" in value 

730 ): 

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

732 else: 

733 return _escape_value(value) 

734 

735 

736_ESCAPE_TABLE = { 

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

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

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

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

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

742} 

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

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

745 

746 

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

748 value_array = bytearray(value.strip()) 

749 ret = bytearray() 

750 whitespace = bytearray() 

751 in_quotes = False 

752 i = 0 

753 while i < len(value_array): 

754 c = value_array[i] 

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

756 i += 1 

757 if i >= len(value_array): 

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

759 if whitespace: 

760 ret.extend(whitespace) 

761 whitespace = bytearray() 

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

763 else: 

764 try: 

765 v = _ESCAPE_TABLE[value_array[i]] 

766 if whitespace: 

767 ret.extend(whitespace) 

768 whitespace = bytearray() 

769 ret.append(v) 

770 except KeyError: 

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

772 if whitespace: 

773 ret.extend(whitespace) 

774 whitespace = bytearray() 

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

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

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

778 in_quotes = not in_quotes 

779 elif c in _COMMENT_CHARS and not in_quotes: 

780 # the rest of the line is a comment 

781 break 

782 elif c in _WHITESPACE_CHARS: 

783 whitespace.append(c) 

784 else: 

785 if whitespace: 

786 ret.extend(whitespace) 

787 whitespace = bytearray() 

788 ret.append(c) 

789 i += 1 

790 

791 if in_quotes: 

792 raise ValueError("missing end quote") 

793 

794 return bytes(ret) 

795 

796 

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

798 """Escape a value.""" 

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

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

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

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

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

804 return value 

805 

806 

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

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

809 c = name[i : i + 1] 

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

811 return False 

812 return True 

813 

814 

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

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

817 c = name[i : i + 1] 

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

819 return False 

820 return True 

821 

822 

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

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

825 quote = ord(b'"') 

826 string_open = False 

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

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

829 # Comment characters outside balanced quotes denote comment start 

830 if character == quote: 

831 string_open = not string_open 

832 elif not string_open and character in comment_bytes: 

833 return line[:i] 

834 return line 

835 

836 

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

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

839 

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

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

842 2. Not within quotes 

843 

844 Args: 

845 value: The value to check 

846 

847 Returns: 

848 True if the value ends with a line continuation backslash 

849 """ 

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

851 return False 

852 

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

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

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

856 else: 

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

858 

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

860 return False 

861 

862 # Count consecutive backslashes at the end 

863 backslash_count = 0 

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

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

866 backslash_count += 1 

867 else: 

868 break 

869 

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

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

872 return backslash_count % 2 == 1 

873 

874 

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

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

877 line = _strip_comments(line).rstrip() 

878 in_quotes = False 

879 escaped = False 

880 for i, c in enumerate(line): 

881 if escaped: 

882 escaped = False 

883 continue 

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

885 in_quotes = not in_quotes 

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

887 escaped = True 

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

889 last = i 

890 break 

891 else: 

892 raise ValueError("expected trailing ]") 

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

894 line = line[last + 1 :] 

895 section: Section 

896 if len(pts) == 2: 

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

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

899 # Standard quoted subsection 

900 pts[1] = pts[1][1:-1] 

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

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

903 # Git allows these without strict quote validation 

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

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

906 pts[1] = pts[1][1:-1] 

907 else: 

908 # Other sections must have quoted subsections 

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

910 if not _check_section_name(pts[0]): 

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

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

913 else: 

914 if not _check_section_name(pts[0]): 

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

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

917 if len(pts) == 2: 

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

919 else: 

920 section = (pts[0],) 

921 return section, line 

922 

923 

924class ConfigFile(ConfigDict): 

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

926 

927 def __init__( 

928 self, 

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

930 | None = None, 

931 encoding: str | None = None, 

932 ) -> None: 

933 """Initialize a ConfigFile. 

934 

935 Args: 

936 values: Optional mapping of configuration values 

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

938 """ 

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

940 self.path: str | None = None 

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

942 

943 @classmethod 

944 def from_file( 

945 cls, 

946 f: IO[bytes], 

947 *, 

948 config_dir: str | None = None, 

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

950 include_depth: int = 0, 

951 max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH, 

952 file_opener: FileOpener | None = None, 

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

954 ) -> "ConfigFile": 

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

956 

957 Args: 

958 f: File-like object to read from 

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

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

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

962 max_include_depth: Maximum allowed include depth 

963 file_opener: Optional callback to open included files 

964 condition_matchers: Optional dict of condition matchers for includeIf 

965 """ 

966 if include_depth > max_include_depth: 

967 # Prevent excessive recursion 

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

969 

970 ret = cls() 

971 if included_paths is not None: 

972 ret._included_paths = included_paths.copy() 

973 

974 section: Section | None = None 

975 setting = None 

976 continuation = None 

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

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

979 line = line[3:] 

980 line = line.lstrip() 

981 if setting is None: 

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

983 section, line = _parse_section_header_line(line) 

984 ret._values.setdefault(section) 

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

986 continue 

987 if section is None: 

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

989 try: 

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

991 except ValueError: 

992 setting = line 

993 value = b"true" 

994 setting = setting.strip() 

995 if not _check_variable_name(setting): 

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

997 if _is_line_continuation(value): 

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

999 continuation = value[:-3] 

1000 else: 

1001 continuation = value[:-2] 

1002 else: 

1003 continuation = None 

1004 value = _parse_string(value) 

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

1006 

1007 # Process include/includeIf directives 

1008 ret._handle_include_directive( 

1009 section, 

1010 setting, 

1011 value, 

1012 config_dir=config_dir, 

1013 include_depth=include_depth, 

1014 max_include_depth=max_include_depth, 

1015 file_opener=file_opener, 

1016 condition_matchers=condition_matchers, 

1017 ) 

1018 

1019 setting = None 

1020 else: # continuation line 

1021 assert continuation is not None 

1022 if _is_line_continuation(line): 

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

1024 continuation += line[:-3] 

1025 else: 

1026 continuation += line[:-2] 

1027 else: 

1028 continuation += line 

1029 value = _parse_string(continuation) 

1030 assert section is not None # Already checked above 

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

1032 

1033 # Process include/includeIf directives 

1034 ret._handle_include_directive( 

1035 section, 

1036 setting, 

1037 value, 

1038 config_dir=config_dir, 

1039 include_depth=include_depth, 

1040 max_include_depth=max_include_depth, 

1041 file_opener=file_opener, 

1042 condition_matchers=condition_matchers, 

1043 ) 

1044 

1045 continuation = None 

1046 setting = None 

1047 return ret 

1048 

1049 def _handle_include_directive( 

1050 self, 

1051 section: Section | None, 

1052 setting: bytes, 

1053 value: bytes, 

1054 *, 

1055 config_dir: str | None, 

1056 include_depth: int, 

1057 max_include_depth: int, 

1058 file_opener: FileOpener | None, 

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

1060 ) -> None: 

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

1062 if ( 

1063 section is not None 

1064 and setting == b"path" 

1065 and ( 

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

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

1068 ) 

1069 ): 

1070 self._process_include( 

1071 section, 

1072 value, 

1073 config_dir=config_dir, 

1074 include_depth=include_depth, 

1075 max_include_depth=max_include_depth, 

1076 file_opener=file_opener, 

1077 condition_matchers=condition_matchers, 

1078 ) 

1079 

1080 def _process_include( 

1081 self, 

1082 section: Section, 

1083 path_value: bytes, 

1084 *, 

1085 config_dir: str | None, 

1086 include_depth: int, 

1087 max_include_depth: int, 

1088 file_opener: FileOpener | None, 

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

1090 ) -> None: 

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

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

1093 

1094 # Handle includeIf conditions 

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

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

1097 if not self._evaluate_includeif_condition( 

1098 condition, config_dir, condition_matchers 

1099 ): 

1100 return 

1101 

1102 # Resolve the include path 

1103 include_path = self._resolve_include_path(path_str, config_dir) 

1104 if not include_path: 

1105 return 

1106 

1107 # Check for circular includes 

1108 try: 

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

1110 except (OSError, ValueError) as e: 

1111 # Invalid path - log and skip 

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

1113 return 

1114 if abs_path in self._included_paths: 

1115 return 

1116 

1117 # Load and merge the included file 

1118 try: 

1119 # Use provided file opener or default to GitFile 

1120 opener: FileOpener 

1121 if file_opener is None: 

1122 

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

1124 return GitFile(path, "rb") 

1125 else: 

1126 opener = file_opener 

1127 

1128 f = opener(include_path) 

1129 except (OSError, ValueError) as e: 

1130 # Git silently ignores missing or unreadable include files 

1131 # Log for debugging purposes 

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

1133 else: 

1134 with f as included_file: 

1135 # Track this path to prevent cycles 

1136 self._included_paths.add(abs_path) 

1137 

1138 # Parse the included file 

1139 included_config = ConfigFile.from_file( 

1140 included_file, 

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

1142 included_paths=self._included_paths, 

1143 include_depth=include_depth + 1, 

1144 max_include_depth=max_include_depth, 

1145 file_opener=file_opener, 

1146 condition_matchers=condition_matchers, 

1147 ) 

1148 

1149 # Merge the included configuration 

1150 self._merge_config(included_config) 

1151 

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

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

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

1155 if section not in self._values: 

1156 self._values[section] = CaseInsensitiveOrderedMultiDict() 

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

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

1159 

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

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

1162 # Expand ~ to home directory 

1163 path = os.path.expanduser(path) 

1164 

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

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

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

1168 

1169 return path 

1170 

1171 def _evaluate_includeif_condition( 

1172 self, 

1173 condition: str, 

1174 config_dir: str | None = None, 

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

1176 ) -> bool: 

1177 """Evaluate an includeIf condition.""" 

1178 # Try custom matchers first if provided 

1179 if condition_matchers: 

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

1181 if condition.startswith(prefix): 

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

1183 

1184 # Fall back to built-in matchers 

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

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

1187 else: 

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

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

1190 return False 

1191 

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

1193 """Evaluate a hasconfig condition. 

1194 

1195 Format: hasconfig:config.key:pattern 

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

1197 """ 

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

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

1200 if len(parts) != 2: 

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

1202 return False 

1203 

1204 config_key, pattern = parts 

1205 

1206 # Parse the config key to get section and name 

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

1208 if len(key_parts) < 2: 

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

1210 return False 

1211 

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

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

1214 # Match any subsection 

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

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

1217 

1218 # Check all sections that match the pattern 

1219 for section in self.sections(): 

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

1221 try: 

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

1223 for value in values: 

1224 if self._match_hasconfig_pattern(value, pattern): 

1225 return True 

1226 except KeyError: 

1227 continue 

1228 else: 

1229 # Direct section lookup 

1230 if len(key_parts) == 2: 

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

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

1233 else: 

1234 section = ( 

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

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

1237 ) 

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

1239 

1240 try: 

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

1242 for value in values: 

1243 if self._match_hasconfig_pattern(value, pattern): 

1244 return True 

1245 except KeyError: 

1246 pass 

1247 

1248 return False 

1249 

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

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

1252 

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

1254 """ 

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

1256 return match_glob_pattern(value_str, pattern) 

1257 

1258 @classmethod 

1259 def from_path( 

1260 cls, 

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

1262 *, 

1263 max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH, 

1264 file_opener: FileOpener | None = None, 

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

1266 ) -> "ConfigFile": 

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

1268 

1269 Args: 

1270 path: Path to the configuration file 

1271 max_include_depth: Maximum allowed include depth 

1272 file_opener: Optional callback to open included files 

1273 condition_matchers: Optional dict of condition matchers for includeIf 

1274 """ 

1275 abs_path = os.fspath(path) 

1276 config_dir = os.path.dirname(abs_path) 

1277 

1278 # Use provided file opener or default to GitFile 

1279 opener: FileOpener 

1280 if file_opener is None: 

1281 

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

1283 return GitFile(p, "rb") 

1284 else: 

1285 opener = file_opener 

1286 

1287 with opener(abs_path) as f: 

1288 ret = cls.from_file( 

1289 f, 

1290 config_dir=config_dir, 

1291 max_include_depth=max_include_depth, 

1292 file_opener=file_opener, 

1293 condition_matchers=condition_matchers, 

1294 ) 

1295 ret.path = abs_path 

1296 return ret 

1297 

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

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

1300 if path is None: 

1301 if self.path is None: 

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

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

1304 else: 

1305 path_to_use = path 

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

1307 self.write_to_file(f) 

1308 

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

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

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

1312 try: 

1313 section_name, subsection_name = section 

1314 except ValueError: 

1315 (section_name,) = section 

1316 subsection_name = None 

1317 if subsection_name is None: 

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

1319 else: 

1320 f.write(b"[" + section_name + b' "' + subsection_name + b'"]\n') 

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

1322 value = _format_string(value) 

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

1324 

1325 

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

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

1328 

1329 Args: 

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

1331 

1332 Returns: 

1333 Full path in XDG config home directory 

1334 """ 

1335 xdg_config_home = os.environ.get( 

1336 "XDG_CONFIG_HOME", 

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

1338 ) 

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

1340 

1341 

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

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

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

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

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

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

1348 # 

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

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

1351 yield git_dir 

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

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

1354 yield parent_dir 

1355 break 

1356 

1357 

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

1359 import platform 

1360 import winreg 

1361 

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

1363 subkey = ( 

1364 "SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\" 

1365 "CurrentVersion\\Uninstall\\Git_is1" 

1366 ) 

1367 else: 

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

1369 

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

1371 with suppress(OSError): 

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

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

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

1375 yield val 

1376 

1377 

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

1379# following: 

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

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

1382# system registry 

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

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

1385 

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

1387 """ 

1388 # Try to find Git installation from PATH first 

1389 for git_dir in _find_git_in_win_path(): 

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

1391 return # Only use the first found path 

1392 

1393 # Fall back to registry if not found in PATH 

1394 for git_dir in _find_git_in_win_reg(): 

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

1396 return # Only use the first found path 

1397 

1398 

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

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

1401 

1402 Returns all possible config paths including deprecated locations. 

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

1404 """ 

1405 # Include deprecated PROGRAMDATA location 

1406 if "PROGRAMDATA" in os.environ: 

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

1408 

1409 # Include all Git installations found 

1410 for git_dir in _find_git_in_win_path(): 

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

1412 for git_dir in _find_git_in_win_reg(): 

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

1414 

1415 

1416class StackedConfig(Config): 

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

1418 

1419 def __init__( 

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

1421 ) -> None: 

1422 """Initialize a StackedConfig. 

1423 

1424 Args: 

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

1426 writable: Optional config file to write changes to 

1427 """ 

1428 self.backends = backends 

1429 self.writable = writable 

1430 

1431 def __repr__(self) -> str: 

1432 """Return string representation of StackedConfig.""" 

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

1434 

1435 @classmethod 

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

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

1438 

1439 Returns: 

1440 StackedConfig with default configuration files loaded 

1441 """ 

1442 return cls(cls.default_backends()) 

1443 

1444 @classmethod 

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

1446 """Retrieve the default configuration. 

1447 

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

1449 """ 

1450 paths = [] 

1451 

1452 # Handle GIT_CONFIG_GLOBAL - overrides user config paths 

1453 try: 

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

1455 except KeyError: 

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

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

1458 

1459 # Handle GIT_CONFIG_SYSTEM and GIT_CONFIG_NOSYSTEM 

1460 try: 

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

1462 except KeyError: 

1463 if "GIT_CONFIG_NOSYSTEM" not in os.environ: 

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

1465 if sys.platform == "win32": 

1466 paths.extend(get_win_system_paths()) 

1467 

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

1469 

1470 backends = [] 

1471 for path in paths: 

1472 try: 

1473 cf = ConfigFile.from_path(path) 

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

1475 except FileNotFoundError: 

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

1477 continue 

1478 backends.append(cf) 

1479 return backends 

1480 

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

1482 """Get value from configuration.""" 

1483 if not isinstance(section, tuple): 

1484 section = (section,) 

1485 for backend in self.backends: 

1486 try: 

1487 return backend.get(section, name) 

1488 except KeyError: 

1489 pass 

1490 raise KeyError(name) 

1491 

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

1493 """Get multiple values from configuration.""" 

1494 if not isinstance(section, tuple): 

1495 section = (section,) 

1496 for backend in self.backends: 

1497 try: 

1498 yield from backend.get_multivar(section, name) 

1499 except KeyError: 

1500 pass 

1501 

1502 def set( 

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

1504 ) -> None: 

1505 """Set value in configuration.""" 

1506 if self.writable is None: 

1507 raise NotImplementedError(self.set) 

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

1509 

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

1511 """Get all sections.""" 

1512 seen = set() 

1513 for backend in self.backends: 

1514 for section in backend.sections(): 

1515 if section not in seen: 

1516 seen.add(section) 

1517 yield section 

1518 

1519 

1520def read_submodules( 

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

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

1523 """Read a .gitmodules file.""" 

1524 cfg = ConfigFile.from_path(path) 

1525 return parse_submodules(cfg) 

1526 

1527 

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

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

1530 

1531 Args: 

1532 config: A `ConfigFile` 

1533 Returns: 

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

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

1536 """ 

1537 for section in config.sections(): 

1538 section_kind, section_name = section 

1539 if section_kind == b"submodule": 

1540 try: 

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

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

1543 yield (sm_path, sm_url, section_name) 

1544 except KeyError: 

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

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

1547 # how git itself handles malformed .gitmodule entries. 

1548 pass 

1549 

1550 

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

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

1553 for section in config.sections(): 

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

1555 continue 

1556 replacement = section[1] 

1557 try: 

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

1559 except KeyError: 

1560 needles = [] 

1561 if push: 

1562 try: 

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

1564 except KeyError: 

1565 pass 

1566 for needle in needles: 

1567 assert isinstance(needle, bytes) 

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

1569 

1570 

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

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

1573 longest_needle = "" 

1574 updated_url = orig_url 

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

1576 if not orig_url.startswith(needle): 

1577 continue 

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

1579 longest_needle = needle 

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

1581 return updated_url