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

730 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 

28import logging 

29import os 

30import re 

31import sys 

32from collections.abc import ( 

33 Callable, 

34 ItemsView, 

35 Iterable, 

36 Iterator, 

37 KeysView, 

38 Mapping, 

39 MutableMapping, 

40 ValuesView, 

41) 

42from contextlib import suppress 

43from pathlib import Path 

44from typing import ( 

45 IO, 

46 Generic, 

47 TypeVar, 

48 Union, 

49 overload, 

50) 

51 

52from .file import GitFile, _GitFile 

53 

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

55ConfigValue = str | bytes | bool | int 

56 

57logger = logging.getLogger(__name__) 

58 

59# Type for file opener callback 

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

61 

62# Type for includeIf condition matcher 

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

64ConditionMatcher = Callable[[str], bool] 

65 

66# Security limits for include files 

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

68DEFAULT_MAX_INCLUDE_DEPTH = 10 # Maximum recursion depth for includes 

69 

70 

71def _match_gitdir_pattern( 

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

73) -> bool: 

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

75 

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

77 """ 

78 # Convert to strings for easier manipulation 

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

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

81 

82 # Normalize paths to use forward slashes for consistent matching 

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

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

85 

86 if ignorecase: 

87 path_str = path_str.lower() 

88 pattern_str = pattern_str.lower() 

89 

90 # Handle the common cases for gitdir patterns 

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

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

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

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

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

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

97 # Pattern like **/filename 

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

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

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

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

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

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

104 elif "**" in pattern_str: 

105 # Handle patterns with ** in the middle 

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

107 if len(parts) == 2: 

108 prefix, suffix = parts 

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

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

111 return False 

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

113 return False 

114 return True 

115 

116 # Direct match or simple glob pattern 

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

118 import fnmatch 

119 

120 return fnmatch.fnmatch(path_str, pattern_str) 

121 else: 

122 return path_str == pattern_str 

123 

124 

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

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

127 

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

129 

130 Raises: 

131 ValueError: If the pattern is invalid 

132 """ 

133 # Convert glob pattern to regex 

134 pattern_escaped = re.escape(pattern) 

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

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

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

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

139 # Anchor the pattern 

140 pattern_regex = f"^{pattern_escaped}$" 

141 

142 try: 

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

144 except re.error as e: 

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

146 

147 

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

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

150 

151 Args: 

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

153 

154 Returns: 

155 Key with section names lowercased, subsection names preserved 

156 

157 Raises: 

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

159 """ 

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

161 return key.lower() 

162 

163 if isinstance(key, tuple): 

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

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

166 if len(key) > 0: 

167 first = key[0] 

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

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

170 return key 

171 

172 raise TypeError(key) 

173 

174 

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

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

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

178 

179 

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

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

182 

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

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

185 """ 

186 

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

188 """Initialize a CaseInsensitiveOrderedMultiDict. 

189 

190 Args: 

191 default_factory: Optional factory function for default values 

192 """ 

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

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

195 self._default_factory = default_factory 

196 

197 @classmethod 

198 def make( 

199 cls, 

200 dict_in: Union[MutableMapping[K, V], "CaseInsensitiveOrderedMultiDict[K, V]"] 

201 | None = None, 

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

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

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

205 

206 Args: 

207 dict_in: Optional mapping to initialize from 

208 default_factory: Optional factory function for default values 

209 

210 Returns: 

211 New CaseInsensitiveOrderedMultiDict instance 

212 

213 Raises: 

214 TypeError: If dict_in is not a mapping or None 

215 """ 

216 if isinstance(dict_in, cls): 

217 return dict_in 

218 

219 out = cls(default_factory=default_factory) 

220 

221 if dict_in is None: 

222 return out 

223 

224 if not isinstance(dict_in, MutableMapping): 

225 raise TypeError 

226 

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

228 out[key] = value 

229 

230 return out 

231 

232 def __len__(self) -> int: 

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

234 return len(self._keyed) 

235 

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

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

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

239 # We need to deduplicate since _real can have duplicates 

240 seen = set() 

241 unique_keys = [] 

242 for k, _ in self._real: 

243 lower = lower_key(k) 

244 if lower not in seen: 

245 seen.add(lower) 

246 unique_keys.append(k) 

247 from collections.abc import KeysView as ABCKeysView 

248 

249 class UniqueKeysView(ABCKeysView[K]): 

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

251 self._keys = keys 

252 

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

254 return key in self._keys 

255 

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

257 return iter(self._keys) 

258 

259 def __len__(self) -> int: 

260 return len(self._keys) 

261 

262 return UniqueKeysView(unique_keys) 

263 

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

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

266 

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

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

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

270 

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

272 self._mapping = mapping 

273 

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

275 return iter(self._mapping._real) 

276 

277 def __len__(self) -> int: 

278 return len(self._mapping._real) 

279 

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

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

282 return False 

283 key, value = item 

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

285 

286 return OrderedItemsView(self) 

287 

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

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

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

291 seen = set() 

292 for k, _ in self._real: 

293 lower = lower_key(k) 

294 if lower not in seen: 

295 seen.add(lower) 

296 yield k 

297 

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

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

300 return self._keyed.values() 

301 

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

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

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

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

306 

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

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

309 

310 Args: 

311 key: The key to set 

312 value: The value to set 

313 """ 

314 # This method replaces all existing values for the key 

315 lower = lower_key(key) 

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

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

318 self._keyed[lower] = value 

319 

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

321 """Delete all values for a key. 

322 

323 Raises: 

324 KeyError: If the key is not found 

325 """ 

326 lower_k = lower_key(key) 

327 del self._keyed[lower_k] 

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

329 if lower_key(actual) == lower_k: 

330 del self._real[i] 

331 

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

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

334 

335 Raises: 

336 KeyError: If the key is not found 

337 """ 

338 return self._keyed[lower_key(item)] 

339 

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

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

342 

343 Args: 

344 key: The key to look up 

345 default: Default value to return if key not found 

346 

347 Returns: 

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

349 """ 

350 try: 

351 return self[key] 

352 except KeyError: 

353 if default is not None: 

354 return default 

355 elif self._default_factory is not None: 

356 return self._default_factory() 

357 else: 

358 return None 

359 

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

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

362 

363 Args: 

364 key: The key to look up 

365 

366 Returns: 

367 Iterator of all values for the key 

368 """ 

369 lowered_key = lower_key(key) 

370 for actual, value in self._real: 

371 if lower_key(actual) == lowered_key: 

372 yield value 

373 

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

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

376 

377 Args: 

378 key: The key to look up 

379 default: Default value to set if key not found 

380 

381 Returns: 

382 The existing value or the newly set default 

383 

384 Raises: 

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

386 """ 

387 try: 

388 return self[key] 

389 except KeyError: 

390 if default is not None: 

391 self[key] = default 

392 return default 

393 elif self._default_factory is not None: 

394 value = self._default_factory() 

395 self[key] = value 

396 return value 

397 else: 

398 raise 

399 

400 

401Name = bytes 

402NameLike = bytes | str 

403Section = tuple[bytes, ...] 

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

405Value = bytes 

406ValueLike = bytes | str 

407 

408 

409class Config: 

410 """A Git configuration.""" 

411 

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

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

414 

415 Args: 

416 section: Tuple with section name and optional subsection name 

417 name: Variable name 

418 Returns: 

419 Contents of the setting 

420 Raises: 

421 KeyError: if the value is not set 

422 """ 

423 raise NotImplementedError(self.get) 

424 

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

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

427 

428 Args: 

429 section: Tuple with section name and optional subsection namee 

430 name: Variable name 

431 Returns: 

432 Contents of the setting as iterable 

433 Raises: 

434 KeyError: if the value is not set 

435 """ 

436 raise NotImplementedError(self.get_multivar) 

437 

438 @overload 

439 def get_boolean( 

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

441 ) -> bool: ... 

442 

443 @overload 

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

445 

446 def get_boolean( 

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

448 ) -> bool | None: 

449 """Retrieve a configuration setting as boolean. 

450 

451 Args: 

452 section: Tuple with section name and optional subsection name 

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

454 subsection. 

455 default: Default value if setting is not found 

456 

457 Returns: 

458 Contents of the setting 

459 """ 

460 try: 

461 value = self.get(section, name) 

462 except KeyError: 

463 return default 

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

465 return True 

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

467 return False 

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

469 

470 def set( 

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

472 ) -> None: 

473 """Set a configuration value. 

474 

475 Args: 

476 section: Tuple with section name and optional subsection namee 

477 name: Name of the configuration value, including section 

478 and optional subsection 

479 value: value of the setting 

480 """ 

481 raise NotImplementedError(self.set) 

482 

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

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

485 

486 Args: 

487 section: Tuple with section name and optional subsection namee 

488 Returns: 

489 Iterator over (name, value) pairs 

490 """ 

491 raise NotImplementedError(self.items) 

492 

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

494 """Iterate over the sections. 

495 

496 Returns: Iterator over section tuples 

497 """ 

498 raise NotImplementedError(self.sections) 

499 

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

501 """Check if a specified section exists. 

502 

503 Args: 

504 name: Name of section to check for 

505 Returns: 

506 boolean indicating whether the section exists 

507 """ 

508 return name in self.sections() 

509 

510 

511class ConfigDict(Config): 

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

513 

514 def __init__( 

515 self, 

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

517 | None = None, 

518 encoding: str | None = None, 

519 ) -> None: 

520 """Create a new ConfigDict.""" 

521 if encoding is None: 

522 encoding = sys.getdefaultencoding() 

523 self.encoding = encoding 

524 self._values: CaseInsensitiveOrderedMultiDict[ 

525 Section, CaseInsensitiveOrderedMultiDict[Name, Value] 

526 ] = CaseInsensitiveOrderedMultiDict.make( 

527 values, default_factory=CaseInsensitiveOrderedMultiDict 

528 ) 

529 

530 def __repr__(self) -> str: 

531 """Return string representation of ConfigDict.""" 

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

533 

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

535 """Check equality with another ConfigDict.""" 

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

537 

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

539 """Get configuration values for a section. 

540 

541 Raises: 

542 KeyError: If section not found 

543 """ 

544 return self._values.__getitem__(key) 

545 

546 def __setitem__( 

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

548 ) -> None: 

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

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

551 

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

553 """Delete a configuration section. 

554 

555 Raises: 

556 KeyError: If section not found 

557 """ 

558 return self._values.__delitem__(key) 

559 

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

561 """Iterate over configuration sections.""" 

562 return self._values.__iter__() 

563 

564 def __len__(self) -> int: 

565 """Return the number of sections.""" 

566 return self._values.__len__() 

567 

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

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

570 return self._values.keys() 

571 

572 @classmethod 

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

574 parts = name.split(".") 

575 if len(parts) == 3: 

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

577 else: 

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

579 

580 def _check_section_and_name( 

581 self, section: SectionLike, name: NameLike 

582 ) -> tuple[Section, Name]: 

583 if not isinstance(section, tuple): 

584 section = (section,) 

585 

586 checked_section = tuple( 

587 [ 

588 subsection.encode(self.encoding) 

589 if not isinstance(subsection, bytes) 

590 else subsection 

591 for subsection in section 

592 ] 

593 ) 

594 

595 if not isinstance(name, bytes): 

596 name = name.encode(self.encoding) 

597 

598 return checked_section, name 

599 

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

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

602 

603 Args: 

604 section: Section name 

605 name: Setting name 

606 

607 Returns: 

608 Iterator of configuration values 

609 """ 

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

611 

612 if len(section) > 1: 

613 try: 

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

615 except KeyError: 

616 pass 

617 

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

619 

620 def get( 

621 self, 

622 section: SectionLike, 

623 name: NameLike, 

624 ) -> Value: 

625 """Get a configuration value. 

626 

627 Args: 

628 section: Section name 

629 name: Setting name 

630 

631 Returns: 

632 Configuration value 

633 

634 Raises: 

635 KeyError: if the value is not set 

636 """ 

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

638 

639 if len(section) > 1: 

640 try: 

641 return self._values[section][name] 

642 except KeyError: 

643 pass 

644 

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

646 

647 def set( 

648 self, 

649 section: SectionLike, 

650 name: NameLike, 

651 value: ValueLike | bool, 

652 ) -> None: 

653 """Set a configuration value. 

654 

655 Args: 

656 section: Section name 

657 name: Setting name 

658 value: Configuration value 

659 """ 

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

661 

662 if isinstance(value, bool): 

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

664 

665 if not isinstance(value, bytes): 

666 value = value.encode(self.encoding) 

667 

668 section_dict = self._values.setdefault(section) 

669 if hasattr(section_dict, "set"): 

670 section_dict.set(name, value) 

671 else: 

672 section_dict[name] = value 

673 

674 def add( 

675 self, 

676 section: SectionLike, 

677 name: NameLike, 

678 value: ValueLike | bool, 

679 ) -> None: 

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

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 self._values.setdefault(section)[name] = value 

690 

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

692 """Get items in a section.""" 

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

694 section_dict = self._values.get(section_bytes) 

695 if section_dict is not None: 

696 return iter(section_dict.items()) 

697 return iter([]) 

698 

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

700 """Get all sections.""" 

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

702 

703 

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

705 if ( 

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

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

708 or b"#" in value 

709 ): 

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

711 else: 

712 return _escape_value(value) 

713 

714 

715_ESCAPE_TABLE = { 

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

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

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

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

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

721} 

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

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

724 

725 

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

727 value_array = bytearray(value.strip()) 

728 ret = bytearray() 

729 whitespace = bytearray() 

730 in_quotes = False 

731 i = 0 

732 while i < len(value_array): 

733 c = value_array[i] 

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

735 i += 1 

736 if i >= len(value_array): 

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

738 if whitespace: 

739 ret.extend(whitespace) 

740 whitespace = bytearray() 

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

742 else: 

743 try: 

744 v = _ESCAPE_TABLE[value_array[i]] 

745 if whitespace: 

746 ret.extend(whitespace) 

747 whitespace = bytearray() 

748 ret.append(v) 

749 except KeyError: 

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

751 if whitespace: 

752 ret.extend(whitespace) 

753 whitespace = bytearray() 

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

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

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

757 in_quotes = not in_quotes 

758 elif c in _COMMENT_CHARS and not in_quotes: 

759 # the rest of the line is a comment 

760 break 

761 elif c in _WHITESPACE_CHARS: 

762 whitespace.append(c) 

763 else: 

764 if whitespace: 

765 ret.extend(whitespace) 

766 whitespace = bytearray() 

767 ret.append(c) 

768 i += 1 

769 

770 if in_quotes: 

771 raise ValueError("missing end quote") 

772 

773 return bytes(ret) 

774 

775 

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

777 """Escape a value.""" 

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

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

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

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

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

783 return value 

784 

785 

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

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

788 c = name[i : i + 1] 

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

790 return False 

791 return True 

792 

793 

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

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

796 c = name[i : i + 1] 

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

798 return False 

799 return True 

800 

801 

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

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

804 quote = ord(b'"') 

805 string_open = False 

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

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

808 # Comment characters outside balanced quotes denote comment start 

809 if character == quote: 

810 string_open = not string_open 

811 elif not string_open and character in comment_bytes: 

812 return line[:i] 

813 return line 

814 

815 

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

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

818 

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

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

821 2. Not within quotes 

822 

823 Args: 

824 value: The value to check 

825 

826 Returns: 

827 True if the value ends with a line continuation backslash 

828 """ 

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

830 return False 

831 

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

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

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

835 else: 

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

837 

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

839 return False 

840 

841 # Count consecutive backslashes at the end 

842 backslash_count = 0 

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

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

845 backslash_count += 1 

846 else: 

847 break 

848 

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

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

851 return backslash_count % 2 == 1 

852 

853 

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

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

856 line = _strip_comments(line).rstrip() 

857 in_quotes = False 

858 escaped = False 

859 for i, c in enumerate(line): 

860 if escaped: 

861 escaped = False 

862 continue 

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

864 in_quotes = not in_quotes 

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

866 escaped = True 

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

868 last = i 

869 break 

870 else: 

871 raise ValueError("expected trailing ]") 

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

873 line = line[last + 1 :] 

874 section: Section 

875 if len(pts) == 2: 

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

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

878 # Standard quoted subsection 

879 pts[1] = pts[1][1:-1] 

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

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

882 # Git allows these without strict quote validation 

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

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

885 pts[1] = pts[1][1:-1] 

886 else: 

887 # Other sections must have quoted subsections 

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

889 if not _check_section_name(pts[0]): 

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

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

892 else: 

893 if not _check_section_name(pts[0]): 

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

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

896 if len(pts) == 2: 

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

898 else: 

899 section = (pts[0],) 

900 return section, line 

901 

902 

903class ConfigFile(ConfigDict): 

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

905 

906 def __init__( 

907 self, 

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

909 | None = None, 

910 encoding: str | None = None, 

911 ) -> None: 

912 """Initialize a ConfigFile. 

913 

914 Args: 

915 values: Optional mapping of configuration values 

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

917 """ 

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

919 self.path: str | None = None 

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

921 

922 @classmethod 

923 def from_file( 

924 cls, 

925 f: IO[bytes], 

926 *, 

927 config_dir: str | None = None, 

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

929 include_depth: int = 0, 

930 max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH, 

931 file_opener: FileOpener | None = None, 

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

933 ) -> "ConfigFile": 

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

935 

936 Args: 

937 f: File-like object to read from 

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

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

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

941 max_include_depth: Maximum allowed include depth 

942 file_opener: Optional callback to open included files 

943 condition_matchers: Optional dict of condition matchers for includeIf 

944 """ 

945 if include_depth > max_include_depth: 

946 # Prevent excessive recursion 

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

948 

949 ret = cls() 

950 if included_paths is not None: 

951 ret._included_paths = included_paths.copy() 

952 

953 section: Section | None = None 

954 setting = None 

955 continuation = None 

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

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

958 line = line[3:] 

959 line = line.lstrip() 

960 if setting is None: 

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

962 section, line = _parse_section_header_line(line) 

963 ret._values.setdefault(section) 

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

965 continue 

966 if section is None: 

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

968 try: 

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

970 except ValueError: 

971 setting = line 

972 value = b"true" 

973 setting = setting.strip() 

974 if not _check_variable_name(setting): 

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

976 if _is_line_continuation(value): 

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

978 continuation = value[:-3] 

979 else: 

980 continuation = value[:-2] 

981 else: 

982 continuation = None 

983 value = _parse_string(value) 

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

985 

986 # Process include/includeIf directives 

987 ret._handle_include_directive( 

988 section, 

989 setting, 

990 value, 

991 config_dir=config_dir, 

992 include_depth=include_depth, 

993 max_include_depth=max_include_depth, 

994 file_opener=file_opener, 

995 condition_matchers=condition_matchers, 

996 ) 

997 

998 setting = None 

999 else: # continuation line 

1000 assert continuation is not None 

1001 if _is_line_continuation(line): 

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

1003 continuation += line[:-3] 

1004 else: 

1005 continuation += line[:-2] 

1006 else: 

1007 continuation += line 

1008 value = _parse_string(continuation) 

1009 assert section is not None # Already checked above 

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

1011 

1012 # Process include/includeIf directives 

1013 ret._handle_include_directive( 

1014 section, 

1015 setting, 

1016 value, 

1017 config_dir=config_dir, 

1018 include_depth=include_depth, 

1019 max_include_depth=max_include_depth, 

1020 file_opener=file_opener, 

1021 condition_matchers=condition_matchers, 

1022 ) 

1023 

1024 continuation = None 

1025 setting = None 

1026 return ret 

1027 

1028 def _handle_include_directive( 

1029 self, 

1030 section: Section | None, 

1031 setting: bytes, 

1032 value: bytes, 

1033 *, 

1034 config_dir: str | None, 

1035 include_depth: int, 

1036 max_include_depth: int, 

1037 file_opener: FileOpener | None, 

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

1039 ) -> None: 

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

1041 if ( 

1042 section is not None 

1043 and setting == b"path" 

1044 and ( 

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

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

1047 ) 

1048 ): 

1049 self._process_include( 

1050 section, 

1051 value, 

1052 config_dir=config_dir, 

1053 include_depth=include_depth, 

1054 max_include_depth=max_include_depth, 

1055 file_opener=file_opener, 

1056 condition_matchers=condition_matchers, 

1057 ) 

1058 

1059 def _process_include( 

1060 self, 

1061 section: Section, 

1062 path_value: bytes, 

1063 *, 

1064 config_dir: str | None, 

1065 include_depth: int, 

1066 max_include_depth: int, 

1067 file_opener: FileOpener | None, 

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

1069 ) -> None: 

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

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

1072 

1073 # Handle includeIf conditions 

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

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

1076 if not self._evaluate_includeif_condition( 

1077 condition, config_dir, condition_matchers 

1078 ): 

1079 return 

1080 

1081 # Resolve the include path 

1082 include_path = self._resolve_include_path(path_str, config_dir) 

1083 if not include_path: 

1084 return 

1085 

1086 # Check for circular includes 

1087 try: 

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

1089 except (OSError, ValueError) as e: 

1090 # Invalid path - log and skip 

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

1092 return 

1093 if abs_path in self._included_paths: 

1094 return 

1095 

1096 # Load and merge the included file 

1097 try: 

1098 # Use provided file opener or default to GitFile 

1099 opener: FileOpener 

1100 if file_opener is None: 

1101 

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

1103 return GitFile(path, "rb") 

1104 else: 

1105 opener = file_opener 

1106 

1107 f = opener(include_path) 

1108 except (OSError, ValueError) as e: 

1109 # Git silently ignores missing or unreadable include files 

1110 # Log for debugging purposes 

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

1112 else: 

1113 with f as included_file: 

1114 # Track this path to prevent cycles 

1115 self._included_paths.add(abs_path) 

1116 

1117 # Parse the included file 

1118 included_config = ConfigFile.from_file( 

1119 included_file, 

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

1121 included_paths=self._included_paths, 

1122 include_depth=include_depth + 1, 

1123 max_include_depth=max_include_depth, 

1124 file_opener=file_opener, 

1125 condition_matchers=condition_matchers, 

1126 ) 

1127 

1128 # Merge the included configuration 

1129 self._merge_config(included_config) 

1130 

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

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

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

1134 if section not in self._values: 

1135 self._values[section] = CaseInsensitiveOrderedMultiDict() 

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

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

1138 

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

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

1141 # Expand ~ to home directory 

1142 path = os.path.expanduser(path) 

1143 

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

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

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

1147 

1148 return path 

1149 

1150 def _evaluate_includeif_condition( 

1151 self, 

1152 condition: str, 

1153 config_dir: str | None = None, 

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

1155 ) -> bool: 

1156 """Evaluate an includeIf condition.""" 

1157 # Try custom matchers first if provided 

1158 if condition_matchers: 

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

1160 if condition.startswith(prefix): 

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

1162 

1163 # Fall back to built-in matchers 

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

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

1166 else: 

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

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

1169 return False 

1170 

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

1172 """Evaluate a hasconfig condition. 

1173 

1174 Format: hasconfig:config.key:pattern 

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

1176 """ 

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

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

1179 if len(parts) != 2: 

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

1181 return False 

1182 

1183 config_key, pattern = parts 

1184 

1185 # Parse the config key to get section and name 

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

1187 if len(key_parts) < 2: 

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

1189 return False 

1190 

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

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

1193 # Match any subsection 

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

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

1196 

1197 # Check all sections that match the pattern 

1198 for section in self.sections(): 

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

1200 try: 

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

1202 for value in values: 

1203 if self._match_hasconfig_pattern(value, pattern): 

1204 return True 

1205 except KeyError: 

1206 continue 

1207 else: 

1208 # Direct section lookup 

1209 if len(key_parts) == 2: 

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

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

1212 else: 

1213 section = ( 

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

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

1216 ) 

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

1218 

1219 try: 

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

1221 for value in values: 

1222 if self._match_hasconfig_pattern(value, pattern): 

1223 return True 

1224 except KeyError: 

1225 pass 

1226 

1227 return False 

1228 

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

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

1231 

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

1233 """ 

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

1235 return match_glob_pattern(value_str, pattern) 

1236 

1237 @classmethod 

1238 def from_path( 

1239 cls, 

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

1241 *, 

1242 max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH, 

1243 file_opener: FileOpener | None = None, 

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

1245 ) -> "ConfigFile": 

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

1247 

1248 Args: 

1249 path: Path to the configuration file 

1250 max_include_depth: Maximum allowed include depth 

1251 file_opener: Optional callback to open included files 

1252 condition_matchers: Optional dict of condition matchers for includeIf 

1253 """ 

1254 abs_path = os.fspath(path) 

1255 config_dir = os.path.dirname(abs_path) 

1256 

1257 # Use provided file opener or default to GitFile 

1258 opener: FileOpener 

1259 if file_opener is None: 

1260 

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

1262 return GitFile(p, "rb") 

1263 else: 

1264 opener = file_opener 

1265 

1266 with opener(abs_path) as f: 

1267 ret = cls.from_file( 

1268 f, 

1269 config_dir=config_dir, 

1270 max_include_depth=max_include_depth, 

1271 file_opener=file_opener, 

1272 condition_matchers=condition_matchers, 

1273 ) 

1274 ret.path = abs_path 

1275 return ret 

1276 

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

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

1279 if path is None: 

1280 if self.path is None: 

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

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

1283 else: 

1284 path_to_use = path 

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

1286 self.write_to_file(f) 

1287 

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

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

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

1291 try: 

1292 section_name, subsection_name = section 

1293 except ValueError: 

1294 (section_name,) = section 

1295 subsection_name = None 

1296 if subsection_name is None: 

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

1298 else: 

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

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

1301 value = _format_string(value) 

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

1303 

1304 

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

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

1307 

1308 Args: 

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

1310 

1311 Returns: 

1312 Full path in XDG config home directory 

1313 """ 

1314 xdg_config_home = os.environ.get( 

1315 "XDG_CONFIG_HOME", 

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

1317 ) 

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

1319 

1320 

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

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

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

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

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

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

1327 # 

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

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

1330 yield git_dir 

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

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

1333 yield parent_dir 

1334 break 

1335 

1336 

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

1338 import platform 

1339 import winreg 

1340 

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

1342 subkey = ( 

1343 "SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\" 

1344 "CurrentVersion\\Uninstall\\Git_is1" 

1345 ) 

1346 else: 

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

1348 

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

1350 with suppress(OSError): 

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

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

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

1354 yield val 

1355 

1356 

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

1358# following: 

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

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

1361# system registry 

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

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

1364 

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

1366 """ 

1367 # Try to find Git installation from PATH first 

1368 for git_dir in _find_git_in_win_path(): 

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

1370 return # Only use the first found path 

1371 

1372 # Fall back to registry if not found in PATH 

1373 for git_dir in _find_git_in_win_reg(): 

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

1375 return # Only use the first found path 

1376 

1377 

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

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

1380 

1381 Returns all possible config paths including deprecated locations. 

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

1383 """ 

1384 # Include deprecated PROGRAMDATA location 

1385 if "PROGRAMDATA" in os.environ: 

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

1387 

1388 # Include all Git installations found 

1389 for git_dir in _find_git_in_win_path(): 

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

1391 for git_dir in _find_git_in_win_reg(): 

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

1393 

1394 

1395class StackedConfig(Config): 

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

1397 

1398 def __init__( 

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

1400 ) -> None: 

1401 """Initialize a StackedConfig. 

1402 

1403 Args: 

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

1405 writable: Optional config file to write changes to 

1406 """ 

1407 self.backends = backends 

1408 self.writable = writable 

1409 

1410 def __repr__(self) -> str: 

1411 """Return string representation of StackedConfig.""" 

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

1413 

1414 @classmethod 

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

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

1417 

1418 Returns: 

1419 StackedConfig with default configuration files loaded 

1420 """ 

1421 return cls(cls.default_backends()) 

1422 

1423 @classmethod 

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

1425 """Retrieve the default configuration. 

1426 

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

1428 """ 

1429 paths = [] 

1430 

1431 # Handle GIT_CONFIG_GLOBAL - overrides user config paths 

1432 try: 

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

1434 except KeyError: 

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

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

1437 

1438 # Handle GIT_CONFIG_SYSTEM and GIT_CONFIG_NOSYSTEM 

1439 try: 

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

1441 except KeyError: 

1442 if "GIT_CONFIG_NOSYSTEM" not in os.environ: 

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

1444 if sys.platform == "win32": 

1445 paths.extend(get_win_system_paths()) 

1446 

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

1448 

1449 backends = [] 

1450 for path in paths: 

1451 try: 

1452 cf = ConfigFile.from_path(path) 

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

1454 except FileNotFoundError: 

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

1456 continue 

1457 backends.append(cf) 

1458 return backends 

1459 

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

1461 """Get value from configuration.""" 

1462 if not isinstance(section, tuple): 

1463 section = (section,) 

1464 for backend in self.backends: 

1465 try: 

1466 return backend.get(section, name) 

1467 except KeyError: 

1468 pass 

1469 raise KeyError(name) 

1470 

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

1472 """Get multiple values from configuration.""" 

1473 if not isinstance(section, tuple): 

1474 section = (section,) 

1475 for backend in self.backends: 

1476 try: 

1477 yield from backend.get_multivar(section, name) 

1478 except KeyError: 

1479 pass 

1480 

1481 def set( 

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

1483 ) -> None: 

1484 """Set value in configuration.""" 

1485 if self.writable is None: 

1486 raise NotImplementedError(self.set) 

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

1488 

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

1490 """Get all sections.""" 

1491 seen = set() 

1492 for backend in self.backends: 

1493 for section in backend.sections(): 

1494 if section not in seen: 

1495 seen.add(section) 

1496 yield section 

1497 

1498 

1499def read_submodules( 

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

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

1502 """Read a .gitmodules file.""" 

1503 cfg = ConfigFile.from_path(path) 

1504 return parse_submodules(cfg) 

1505 

1506 

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

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

1509 

1510 Args: 

1511 config: A `ConfigFile` 

1512 Returns: 

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

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

1515 """ 

1516 for section in config.sections(): 

1517 section_kind, section_name = section 

1518 if section_kind == b"submodule": 

1519 try: 

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

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

1522 yield (sm_path, sm_url, section_name) 

1523 except KeyError: 

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

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

1526 # how git itself handles malformed .gitmodule entries. 

1527 pass 

1528 

1529 

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

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

1532 for section in config.sections(): 

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

1534 continue 

1535 replacement = section[1] 

1536 try: 

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

1538 except KeyError: 

1539 needles = [] 

1540 if push: 

1541 try: 

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

1543 except KeyError: 

1544 pass 

1545 for needle in needles: 

1546 assert isinstance(needle, bytes) 

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

1548 

1549 

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

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

1552 longest_needle = "" 

1553 updated_url = orig_url 

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

1555 if not orig_url.startswith(needle): 

1556 continue 

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

1558 longest_needle = needle 

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

1560 return updated_url