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 overload, 

49) 

50 

51from .file import GitFile, _GitFile 

52 

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

54ConfigValue = str | bytes | bool | int 

55 

56logger = logging.getLogger(__name__) 

57 

58# Type for file opener callback 

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

60 

61# Type for includeIf condition matcher 

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

63ConditionMatcher = Callable[[str], bool] 

64 

65# Security limits for include files 

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

67DEFAULT_MAX_INCLUDE_DEPTH = 10 # Maximum recursion depth for includes 

68 

69 

70def _match_gitdir_pattern( 

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

72) -> bool: 

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

74 

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

76 """ 

77 # Convert to strings for easier manipulation 

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

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

80 

81 # Normalize paths to use forward slashes for consistent matching 

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

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

84 

85 if ignorecase: 

86 path_str = path_str.lower() 

87 pattern_str = pattern_str.lower() 

88 

89 # Handle the common cases for gitdir patterns 

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

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

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

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

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

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

96 # Pattern like **/filename 

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

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

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

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

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

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

103 elif "**" in pattern_str: 

104 # Handle patterns with ** in the middle 

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

106 if len(parts) == 2: 

107 prefix, suffix = parts 

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

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

110 return False 

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

112 return False 

113 return True 

114 

115 # Direct match or simple glob pattern 

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

117 import fnmatch 

118 

119 return fnmatch.fnmatch(path_str, pattern_str) 

120 else: 

121 return path_str == pattern_str 

122 

123 

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

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

126 

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

128 

129 Raises: 

130 ValueError: If the pattern is invalid 

131 """ 

132 # Convert glob pattern to regex 

133 pattern_escaped = re.escape(pattern) 

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

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

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

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

138 # Anchor the pattern 

139 pattern_regex = f"^{pattern_escaped}$" 

140 

141 try: 

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

143 except re.error as e: 

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

145 

146 

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

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

149 

150 Args: 

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

152 

153 Returns: 

154 Key with section names lowercased, subsection names preserved 

155 

156 Raises: 

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

158 """ 

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

160 return key.lower() 

161 

162 if isinstance(key, tuple): 

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

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

165 if len(key) > 0: 

166 first = key[0] 

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

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

169 return key 

170 

171 raise TypeError(key) 

172 

173 

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

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

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

177 

178 

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

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

181 

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

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

184 """ 

185 

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

187 """Initialize a CaseInsensitiveOrderedMultiDict. 

188 

189 Args: 

190 default_factory: Optional factory function for default values 

191 """ 

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

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

194 self._default_factory = default_factory 

195 

196 @classmethod 

197 def make( 

198 cls, 

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

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

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

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

203 

204 Args: 

205 dict_in: Optional mapping to initialize from 

206 default_factory: Optional factory function for default values 

207 

208 Returns: 

209 New CaseInsensitiveOrderedMultiDict instance 

210 

211 Raises: 

212 TypeError: If dict_in is not a mapping or None 

213 """ 

214 if isinstance(dict_in, cls): 

215 return dict_in 

216 

217 out = cls(default_factory=default_factory) 

218 

219 if dict_in is None: 

220 return out 

221 

222 if not isinstance(dict_in, MutableMapping): 

223 raise TypeError 

224 

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

226 out[key] = value 

227 

228 return out 

229 

230 def __len__(self) -> int: 

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

232 return len(self._keyed) 

233 

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

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

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

237 # We need to deduplicate since _real can have duplicates 

238 seen = set() 

239 unique_keys = [] 

240 for k, _ in self._real: 

241 lower = lower_key(k) 

242 if lower not in seen: 

243 seen.add(lower) 

244 unique_keys.append(k) 

245 from collections.abc import KeysView as ABCKeysView 

246 

247 class UniqueKeysView(ABCKeysView[K]): 

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

249 self._keys = keys 

250 

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

252 return key in self._keys 

253 

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

255 return iter(self._keys) 

256 

257 def __len__(self) -> int: 

258 return len(self._keys) 

259 

260 return UniqueKeysView(unique_keys) 

261 

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

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

264 

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

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

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

268 

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

270 self._mapping = mapping 

271 

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

273 return iter(self._mapping._real) 

274 

275 def __len__(self) -> int: 

276 return len(self._mapping._real) 

277 

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

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

280 return False 

281 key, value = item 

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

283 

284 return OrderedItemsView(self) 

285 

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

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

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

289 seen = set() 

290 for k, _ in self._real: 

291 lower = lower_key(k) 

292 if lower not in seen: 

293 seen.add(lower) 

294 yield k 

295 

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

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

298 return self._keyed.values() 

299 

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

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

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

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

304 

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

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

307 

308 Args: 

309 key: The key to set 

310 value: The value to set 

311 """ 

312 # This method replaces all existing values for the key 

313 lower = lower_key(key) 

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

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

316 self._keyed[lower] = value 

317 

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

319 """Delete all values for a key. 

320 

321 Raises: 

322 KeyError: If the key is not found 

323 """ 

324 lower_k = lower_key(key) 

325 del self._keyed[lower_k] 

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

327 if lower_key(actual) == lower_k: 

328 del self._real[i] 

329 

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

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

332 

333 Raises: 

334 KeyError: If the key is not found 

335 """ 

336 return self._keyed[lower_key(item)] 

337 

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

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

340 

341 Args: 

342 key: The key to look up 

343 default: Default value to return if key not found 

344 

345 Returns: 

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

347 """ 

348 try: 

349 return self[key] 

350 except KeyError: 

351 if default is not None: 

352 return default 

353 elif self._default_factory is not None: 

354 return self._default_factory() 

355 else: 

356 return None 

357 

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

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

360 

361 Args: 

362 key: The key to look up 

363 

364 Returns: 

365 Iterator of all values for the key 

366 """ 

367 lowered_key = lower_key(key) 

368 for actual, value in self._real: 

369 if lower_key(actual) == lowered_key: 

370 yield value 

371 

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

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

374 

375 Args: 

376 key: The key to look up 

377 default: Default value to set if key not found 

378 

379 Returns: 

380 The existing value or the newly set default 

381 

382 Raises: 

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

384 """ 

385 try: 

386 return self[key] 

387 except KeyError: 

388 if default is not None: 

389 self[key] = default 

390 return default 

391 elif self._default_factory is not None: 

392 value = self._default_factory() 

393 self[key] = value 

394 return value 

395 else: 

396 raise 

397 

398 

399Name = bytes 

400NameLike = bytes | str 

401Section = tuple[bytes, ...] 

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

403Value = bytes 

404ValueLike = bytes | str 

405 

406 

407class Config: 

408 """A Git configuration.""" 

409 

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

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

412 

413 Args: 

414 section: Tuple with section name and optional subsection name 

415 name: Variable name 

416 Returns: 

417 Contents of the setting 

418 Raises: 

419 KeyError: if the value is not set 

420 """ 

421 raise NotImplementedError(self.get) 

422 

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

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

425 

426 Args: 

427 section: Tuple with section name and optional subsection namee 

428 name: Variable name 

429 Returns: 

430 Contents of the setting as iterable 

431 Raises: 

432 KeyError: if the value is not set 

433 """ 

434 raise NotImplementedError(self.get_multivar) 

435 

436 @overload 

437 def get_boolean( 

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

439 ) -> bool: ... 

440 

441 @overload 

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

443 

444 def get_boolean( 

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

446 ) -> bool | None: 

447 """Retrieve a configuration setting as boolean. 

448 

449 Args: 

450 section: Tuple with section name and optional subsection name 

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

452 subsection. 

453 default: Default value if setting is not found 

454 

455 Returns: 

456 Contents of the setting 

457 """ 

458 try: 

459 value = self.get(section, name) 

460 except KeyError: 

461 return default 

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

463 return True 

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

465 return False 

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

467 

468 def set( 

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

470 ) -> None: 

471 """Set a configuration value. 

472 

473 Args: 

474 section: Tuple with section name and optional subsection namee 

475 name: Name of the configuration value, including section 

476 and optional subsection 

477 value: value of the setting 

478 """ 

479 raise NotImplementedError(self.set) 

480 

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

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

483 

484 Args: 

485 section: Tuple with section name and optional subsection namee 

486 Returns: 

487 Iterator over (name, value) pairs 

488 """ 

489 raise NotImplementedError(self.items) 

490 

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

492 """Iterate over the sections. 

493 

494 Returns: Iterator over section tuples 

495 """ 

496 raise NotImplementedError(self.sections) 

497 

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

499 """Check if a specified section exists. 

500 

501 Args: 

502 name: Name of section to check for 

503 Returns: 

504 boolean indicating whether the section exists 

505 """ 

506 return name in self.sections() 

507 

508 

509class ConfigDict(Config): 

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

511 

512 def __init__( 

513 self, 

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

515 | None = None, 

516 encoding: str | None = None, 

517 ) -> None: 

518 """Create a new ConfigDict.""" 

519 if encoding is None: 

520 encoding = sys.getdefaultencoding() 

521 self.encoding = encoding 

522 self._values: CaseInsensitiveOrderedMultiDict[ 

523 Section, CaseInsensitiveOrderedMultiDict[Name, Value] 

524 ] = CaseInsensitiveOrderedMultiDict.make( 

525 values, default_factory=CaseInsensitiveOrderedMultiDict 

526 ) 

527 

528 def __repr__(self) -> str: 

529 """Return string representation of ConfigDict.""" 

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

531 

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

533 """Check equality with another ConfigDict.""" 

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

535 

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

537 """Get configuration values for a section. 

538 

539 Raises: 

540 KeyError: If section not found 

541 """ 

542 return self._values.__getitem__(key) 

543 

544 def __setitem__( 

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

546 ) -> None: 

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

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

549 

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

551 """Delete a configuration section. 

552 

553 Raises: 

554 KeyError: If section not found 

555 """ 

556 return self._values.__delitem__(key) 

557 

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

559 """Iterate over configuration sections.""" 

560 return self._values.__iter__() 

561 

562 def __len__(self) -> int: 

563 """Return the number of sections.""" 

564 return self._values.__len__() 

565 

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

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

568 return self._values.keys() 

569 

570 @classmethod 

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

572 parts = name.split(".") 

573 if len(parts) == 3: 

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

575 else: 

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

577 

578 def _check_section_and_name( 

579 self, section: SectionLike, name: NameLike 

580 ) -> tuple[Section, Name]: 

581 if not isinstance(section, tuple): 

582 section = (section,) 

583 

584 checked_section = tuple( 

585 [ 

586 subsection.encode(self.encoding) 

587 if not isinstance(subsection, bytes) 

588 else subsection 

589 for subsection in section 

590 ] 

591 ) 

592 

593 if not isinstance(name, bytes): 

594 name = name.encode(self.encoding) 

595 

596 return checked_section, name 

597 

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

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

600 

601 Args: 

602 section: Section name 

603 name: Setting name 

604 

605 Returns: 

606 Iterator of configuration values 

607 """ 

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

609 

610 if len(section) > 1: 

611 try: 

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

613 except KeyError: 

614 pass 

615 

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

617 

618 def get( 

619 self, 

620 section: SectionLike, 

621 name: NameLike, 

622 ) -> Value: 

623 """Get a configuration value. 

624 

625 Args: 

626 section: Section name 

627 name: Setting name 

628 

629 Returns: 

630 Configuration value 

631 

632 Raises: 

633 KeyError: if the value is not set 

634 """ 

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

636 

637 if len(section) > 1: 

638 try: 

639 return self._values[section][name] 

640 except KeyError: 

641 pass 

642 

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

644 

645 def set( 

646 self, 

647 section: SectionLike, 

648 name: NameLike, 

649 value: ValueLike | bool, 

650 ) -> None: 

651 """Set a configuration value. 

652 

653 Args: 

654 section: Section name 

655 name: Setting name 

656 value: Configuration value 

657 """ 

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

659 

660 if isinstance(value, bool): 

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

662 

663 if not isinstance(value, bytes): 

664 value = value.encode(self.encoding) 

665 

666 section_dict = self._values.setdefault(section) 

667 if hasattr(section_dict, "set"): 

668 section_dict.set(name, value) 

669 else: 

670 section_dict[name] = value 

671 

672 def add( 

673 self, 

674 section: SectionLike, 

675 name: NameLike, 

676 value: ValueLike | bool, 

677 ) -> None: 

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

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

680 

681 if isinstance(value, bool): 

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

683 

684 if not isinstance(value, bytes): 

685 value = value.encode(self.encoding) 

686 

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

688 

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

690 """Get items in a section.""" 

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

692 section_dict = self._values.get(section_bytes) 

693 if section_dict is not None: 

694 return iter(section_dict.items()) 

695 return iter([]) 

696 

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

698 """Get all sections.""" 

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

700 

701 

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

703 if ( 

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

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

706 or b"#" in value 

707 ): 

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

709 else: 

710 return _escape_value(value) 

711 

712 

713_ESCAPE_TABLE = { 

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

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

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

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

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

719} 

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

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

722 

723 

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

725 value_array = bytearray(value.strip()) 

726 ret = bytearray() 

727 whitespace = bytearray() 

728 in_quotes = False 

729 i = 0 

730 while i < len(value_array): 

731 c = value_array[i] 

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

733 i += 1 

734 if i >= len(value_array): 

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

736 if whitespace: 

737 ret.extend(whitespace) 

738 whitespace = bytearray() 

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

740 else: 

741 try: 

742 v = _ESCAPE_TABLE[value_array[i]] 

743 if whitespace: 

744 ret.extend(whitespace) 

745 whitespace = bytearray() 

746 ret.append(v) 

747 except KeyError: 

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

749 if whitespace: 

750 ret.extend(whitespace) 

751 whitespace = bytearray() 

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

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

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

755 in_quotes = not in_quotes 

756 elif c in _COMMENT_CHARS and not in_quotes: 

757 # the rest of the line is a comment 

758 break 

759 elif c in _WHITESPACE_CHARS: 

760 whitespace.append(c) 

761 else: 

762 if whitespace: 

763 ret.extend(whitespace) 

764 whitespace = bytearray() 

765 ret.append(c) 

766 i += 1 

767 

768 if in_quotes: 

769 raise ValueError("missing end quote") 

770 

771 return bytes(ret) 

772 

773 

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

775 """Escape a value.""" 

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

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

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

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

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

781 return value 

782 

783 

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

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

786 c = name[i : i + 1] 

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

788 return False 

789 return True 

790 

791 

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

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

794 c = name[i : i + 1] 

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

796 return False 

797 return True 

798 

799 

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

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

802 quote = ord(b'"') 

803 string_open = False 

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

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

806 # Comment characters outside balanced quotes denote comment start 

807 if character == quote: 

808 string_open = not string_open 

809 elif not string_open and character in comment_bytes: 

810 return line[:i] 

811 return line 

812 

813 

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

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

816 

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

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

819 2. Not within quotes 

820 

821 Args: 

822 value: The value to check 

823 

824 Returns: 

825 True if the value ends with a line continuation backslash 

826 """ 

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

828 return False 

829 

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

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

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

833 else: 

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

835 

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

837 return False 

838 

839 # Count consecutive backslashes at the end 

840 backslash_count = 0 

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

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

843 backslash_count += 1 

844 else: 

845 break 

846 

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

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

849 return backslash_count % 2 == 1 

850 

851 

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

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

854 line = _strip_comments(line).rstrip() 

855 in_quotes = False 

856 escaped = False 

857 for i, c in enumerate(line): 

858 if escaped: 

859 escaped = False 

860 continue 

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

862 in_quotes = not in_quotes 

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

864 escaped = True 

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

866 last = i 

867 break 

868 else: 

869 raise ValueError("expected trailing ]") 

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

871 line = line[last + 1 :] 

872 section: Section 

873 if len(pts) == 2: 

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

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

876 # Standard quoted subsection 

877 pts[1] = pts[1][1:-1] 

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

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

880 # Git allows these without strict quote validation 

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

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

883 pts[1] = pts[1][1:-1] 

884 else: 

885 # Other sections must have quoted subsections 

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

887 if not _check_section_name(pts[0]): 

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

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

890 else: 

891 if not _check_section_name(pts[0]): 

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

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

894 if len(pts) == 2: 

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

896 else: 

897 section = (pts[0],) 

898 return section, line 

899 

900 

901class ConfigFile(ConfigDict): 

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

903 

904 def __init__( 

905 self, 

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

907 | None = None, 

908 encoding: str | None = None, 

909 ) -> None: 

910 """Initialize a ConfigFile. 

911 

912 Args: 

913 values: Optional mapping of configuration values 

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

915 """ 

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

917 self.path: str | None = None 

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

919 

920 @classmethod 

921 def from_file( 

922 cls, 

923 f: IO[bytes], 

924 *, 

925 config_dir: str | None = None, 

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

927 include_depth: int = 0, 

928 max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH, 

929 file_opener: FileOpener | None = None, 

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

931 ) -> "ConfigFile": 

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

933 

934 Args: 

935 f: File-like object to read from 

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

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

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

939 max_include_depth: Maximum allowed include depth 

940 file_opener: Optional callback to open included files 

941 condition_matchers: Optional dict of condition matchers for includeIf 

942 """ 

943 if include_depth > max_include_depth: 

944 # Prevent excessive recursion 

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

946 

947 ret = cls() 

948 if included_paths is not None: 

949 ret._included_paths = included_paths.copy() 

950 

951 section: Section | None = None 

952 setting = None 

953 continuation = None 

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

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

956 line = line[3:] 

957 line = line.lstrip() 

958 if setting is None: 

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

960 section, line = _parse_section_header_line(line) 

961 ret._values.setdefault(section) 

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

963 continue 

964 if section is None: 

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

966 try: 

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

968 except ValueError: 

969 setting = line 

970 value = b"true" 

971 setting = setting.strip() 

972 if not _check_variable_name(setting): 

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

974 if _is_line_continuation(value): 

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

976 continuation = value[:-3] 

977 else: 

978 continuation = value[:-2] 

979 else: 

980 continuation = None 

981 value = _parse_string(value) 

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

983 

984 # Process include/includeIf directives 

985 ret._handle_include_directive( 

986 section, 

987 setting, 

988 value, 

989 config_dir=config_dir, 

990 include_depth=include_depth, 

991 max_include_depth=max_include_depth, 

992 file_opener=file_opener, 

993 condition_matchers=condition_matchers, 

994 ) 

995 

996 setting = None 

997 else: # continuation line 

998 assert continuation is not None 

999 if _is_line_continuation(line): 

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

1001 continuation += line[:-3] 

1002 else: 

1003 continuation += line[:-2] 

1004 else: 

1005 continuation += line 

1006 value = _parse_string(continuation) 

1007 assert section is not None # Already checked above 

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

1009 

1010 # Process include/includeIf directives 

1011 ret._handle_include_directive( 

1012 section, 

1013 setting, 

1014 value, 

1015 config_dir=config_dir, 

1016 include_depth=include_depth, 

1017 max_include_depth=max_include_depth, 

1018 file_opener=file_opener, 

1019 condition_matchers=condition_matchers, 

1020 ) 

1021 

1022 continuation = None 

1023 setting = None 

1024 return ret 

1025 

1026 def _handle_include_directive( 

1027 self, 

1028 section: Section | None, 

1029 setting: bytes, 

1030 value: bytes, 

1031 *, 

1032 config_dir: str | None, 

1033 include_depth: int, 

1034 max_include_depth: int, 

1035 file_opener: FileOpener | None, 

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

1037 ) -> None: 

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

1039 if ( 

1040 section is not None 

1041 and setting == b"path" 

1042 and ( 

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

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

1045 ) 

1046 ): 

1047 self._process_include( 

1048 section, 

1049 value, 

1050 config_dir=config_dir, 

1051 include_depth=include_depth, 

1052 max_include_depth=max_include_depth, 

1053 file_opener=file_opener, 

1054 condition_matchers=condition_matchers, 

1055 ) 

1056 

1057 def _process_include( 

1058 self, 

1059 section: Section, 

1060 path_value: bytes, 

1061 *, 

1062 config_dir: str | None, 

1063 include_depth: int, 

1064 max_include_depth: int, 

1065 file_opener: FileOpener | None, 

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

1067 ) -> None: 

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

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

1070 

1071 # Handle includeIf conditions 

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

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

1074 if not self._evaluate_includeif_condition( 

1075 condition, config_dir, condition_matchers 

1076 ): 

1077 return 

1078 

1079 # Resolve the include path 

1080 include_path = self._resolve_include_path(path_str, config_dir) 

1081 if not include_path: 

1082 return 

1083 

1084 # Check for circular includes 

1085 try: 

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

1087 except (OSError, ValueError) as e: 

1088 # Invalid path - log and skip 

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

1090 return 

1091 if abs_path in self._included_paths: 

1092 return 

1093 

1094 # Load and merge the included file 

1095 try: 

1096 # Use provided file opener or default to GitFile 

1097 opener: FileOpener 

1098 if file_opener is None: 

1099 

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

1101 return GitFile(path, "rb") 

1102 else: 

1103 opener = file_opener 

1104 

1105 f = opener(include_path) 

1106 except (OSError, ValueError) as e: 

1107 # Git silently ignores missing or unreadable include files 

1108 # Log for debugging purposes 

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

1110 else: 

1111 with f as included_file: 

1112 # Track this path to prevent cycles 

1113 self._included_paths.add(abs_path) 

1114 

1115 # Parse the included file 

1116 included_config = ConfigFile.from_file( 

1117 included_file, 

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

1119 included_paths=self._included_paths, 

1120 include_depth=include_depth + 1, 

1121 max_include_depth=max_include_depth, 

1122 file_opener=file_opener, 

1123 condition_matchers=condition_matchers, 

1124 ) 

1125 

1126 # Merge the included configuration 

1127 self._merge_config(included_config) 

1128 

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

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

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

1132 if section not in self._values: 

1133 self._values[section] = CaseInsensitiveOrderedMultiDict() 

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

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

1136 

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

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

1139 # Expand ~ to home directory 

1140 path = os.path.expanduser(path) 

1141 

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

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

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

1145 

1146 return path 

1147 

1148 def _evaluate_includeif_condition( 

1149 self, 

1150 condition: str, 

1151 config_dir: str | None = None, 

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

1153 ) -> bool: 

1154 """Evaluate an includeIf condition.""" 

1155 # Try custom matchers first if provided 

1156 if condition_matchers: 

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

1158 if condition.startswith(prefix): 

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

1160 

1161 # Fall back to built-in matchers 

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

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

1164 else: 

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

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

1167 return False 

1168 

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

1170 """Evaluate a hasconfig condition. 

1171 

1172 Format: hasconfig:config.key:pattern 

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

1174 """ 

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

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

1177 if len(parts) != 2: 

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

1179 return False 

1180 

1181 config_key, pattern = parts 

1182 

1183 # Parse the config key to get section and name 

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

1185 if len(key_parts) < 2: 

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

1187 return False 

1188 

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

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

1191 # Match any subsection 

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

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

1194 

1195 # Check all sections that match the pattern 

1196 for section in self.sections(): 

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

1198 try: 

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

1200 for value in values: 

1201 if self._match_hasconfig_pattern(value, pattern): 

1202 return True 

1203 except KeyError: 

1204 continue 

1205 else: 

1206 # Direct section lookup 

1207 if len(key_parts) == 2: 

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

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

1210 else: 

1211 section = ( 

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

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

1214 ) 

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

1216 

1217 try: 

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

1219 for value in values: 

1220 if self._match_hasconfig_pattern(value, pattern): 

1221 return True 

1222 except KeyError: 

1223 pass 

1224 

1225 return False 

1226 

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

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

1229 

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

1231 """ 

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

1233 return match_glob_pattern(value_str, pattern) 

1234 

1235 @classmethod 

1236 def from_path( 

1237 cls, 

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

1239 *, 

1240 max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH, 

1241 file_opener: FileOpener | None = None, 

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

1243 ) -> "ConfigFile": 

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

1245 

1246 Args: 

1247 path: Path to the configuration file 

1248 max_include_depth: Maximum allowed include depth 

1249 file_opener: Optional callback to open included files 

1250 condition_matchers: Optional dict of condition matchers for includeIf 

1251 """ 

1252 abs_path = os.fspath(path) 

1253 config_dir = os.path.dirname(abs_path) 

1254 

1255 # Use provided file opener or default to GitFile 

1256 opener: FileOpener 

1257 if file_opener is None: 

1258 

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

1260 return GitFile(p, "rb") 

1261 else: 

1262 opener = file_opener 

1263 

1264 with opener(abs_path) as f: 

1265 ret = cls.from_file( 

1266 f, 

1267 config_dir=config_dir, 

1268 max_include_depth=max_include_depth, 

1269 file_opener=file_opener, 

1270 condition_matchers=condition_matchers, 

1271 ) 

1272 ret.path = abs_path 

1273 return ret 

1274 

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

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

1277 if path is None: 

1278 if self.path is None: 

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

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

1281 else: 

1282 path_to_use = path 

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

1284 self.write_to_file(f) 

1285 

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

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

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

1289 try: 

1290 section_name, subsection_name = section 

1291 except ValueError: 

1292 (section_name,) = section 

1293 subsection_name = None 

1294 if subsection_name is None: 

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

1296 else: 

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

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

1299 value = _format_string(value) 

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

1301 

1302 

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

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

1305 

1306 Args: 

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

1308 

1309 Returns: 

1310 Full path in XDG config home directory 

1311 """ 

1312 xdg_config_home = os.environ.get( 

1313 "XDG_CONFIG_HOME", 

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

1315 ) 

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

1317 

1318 

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

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

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

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

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

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

1325 # 

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

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

1328 yield git_dir 

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

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

1331 yield parent_dir 

1332 break 

1333 

1334 

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

1336 import platform 

1337 import winreg 

1338 

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

1340 subkey = ( 

1341 "SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\" 

1342 "CurrentVersion\\Uninstall\\Git_is1" 

1343 ) 

1344 else: 

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

1346 

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

1348 with suppress(OSError): 

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

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

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

1352 yield val 

1353 

1354 

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

1356# following: 

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

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

1359# system registry 

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

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

1362 

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

1364 """ 

1365 # Try to find Git installation from PATH first 

1366 for git_dir in _find_git_in_win_path(): 

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

1368 return # Only use the first found path 

1369 

1370 # Fall back to registry if not found in PATH 

1371 for git_dir in _find_git_in_win_reg(): 

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

1373 return # Only use the first found path 

1374 

1375 

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

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

1378 

1379 Returns all possible config paths including deprecated locations. 

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

1381 """ 

1382 # Include deprecated PROGRAMDATA location 

1383 if "PROGRAMDATA" in os.environ: 

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

1385 

1386 # Include all Git installations found 

1387 for git_dir in _find_git_in_win_path(): 

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

1389 for git_dir in _find_git_in_win_reg(): 

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

1391 

1392 

1393class StackedConfig(Config): 

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

1395 

1396 def __init__( 

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

1398 ) -> None: 

1399 """Initialize a StackedConfig. 

1400 

1401 Args: 

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

1403 writable: Optional config file to write changes to 

1404 """ 

1405 self.backends = backends 

1406 self.writable = writable 

1407 

1408 def __repr__(self) -> str: 

1409 """Return string representation of StackedConfig.""" 

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

1411 

1412 @classmethod 

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

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

1415 

1416 Returns: 

1417 StackedConfig with default configuration files loaded 

1418 """ 

1419 return cls(cls.default_backends()) 

1420 

1421 @classmethod 

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

1423 """Retrieve the default configuration. 

1424 

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

1426 """ 

1427 paths = [] 

1428 

1429 # Handle GIT_CONFIG_GLOBAL - overrides user config paths 

1430 try: 

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

1432 except KeyError: 

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

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

1435 

1436 # Handle GIT_CONFIG_SYSTEM and GIT_CONFIG_NOSYSTEM 

1437 try: 

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

1439 except KeyError: 

1440 if "GIT_CONFIG_NOSYSTEM" not in os.environ: 

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

1442 if sys.platform == "win32": 

1443 paths.extend(get_win_system_paths()) 

1444 

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

1446 

1447 backends = [] 

1448 for path in paths: 

1449 try: 

1450 cf = ConfigFile.from_path(path) 

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

1452 except FileNotFoundError: 

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

1454 continue 

1455 backends.append(cf) 

1456 return backends 

1457 

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

1459 """Get value from configuration.""" 

1460 if not isinstance(section, tuple): 

1461 section = (section,) 

1462 for backend in self.backends: 

1463 try: 

1464 return backend.get(section, name) 

1465 except KeyError: 

1466 pass 

1467 raise KeyError(name) 

1468 

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

1470 """Get multiple values from configuration.""" 

1471 if not isinstance(section, tuple): 

1472 section = (section,) 

1473 for backend in self.backends: 

1474 try: 

1475 yield from backend.get_multivar(section, name) 

1476 except KeyError: 

1477 pass 

1478 

1479 def set( 

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

1481 ) -> None: 

1482 """Set value in configuration.""" 

1483 if self.writable is None: 

1484 raise NotImplementedError(self.set) 

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

1486 

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

1488 """Get all sections.""" 

1489 seen = set() 

1490 for backend in self.backends: 

1491 for section in backend.sections(): 

1492 if section not in seen: 

1493 seen.add(section) 

1494 yield section 

1495 

1496 

1497def read_submodules( 

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

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

1500 """Read a .gitmodules file.""" 

1501 cfg = ConfigFile.from_path(path) 

1502 return parse_submodules(cfg) 

1503 

1504 

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

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

1507 

1508 Args: 

1509 config: A `ConfigFile` 

1510 Returns: 

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

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

1513 """ 

1514 for section in config.sections(): 

1515 section_kind, section_name = section 

1516 if section_kind == b"submodule": 

1517 try: 

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

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

1520 yield (sm_path, sm_url, section_name) 

1521 except KeyError: 

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

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

1524 # how git itself handles malformed .gitmodule entries. 

1525 pass 

1526 

1527 

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

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

1530 for section in config.sections(): 

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

1532 continue 

1533 replacement = section[1] 

1534 try: 

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

1536 except KeyError: 

1537 needles = [] 

1538 if push: 

1539 try: 

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

1541 except KeyError: 

1542 pass 

1543 for needle in needles: 

1544 assert isinstance(needle, bytes) 

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

1546 

1547 

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

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

1550 longest_needle = "" 

1551 updated_url = orig_url 

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

1553 if not orig_url.startswith(needle): 

1554 continue 

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

1556 longest_needle = needle 

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

1558 return updated_url