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

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

708 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 ItemsView, 

34 Iterable, 

35 Iterator, 

36 KeysView, 

37 MutableMapping, 

38 ValuesView, 

39) 

40from contextlib import suppress 

41from pathlib import Path 

42from typing import ( 

43 IO, 

44 Any, 

45 BinaryIO, 

46 Callable, 

47 Generic, 

48 Optional, 

49 TypeVar, 

50 Union, 

51 cast, 

52 overload, 

53) 

54 

55from .file import GitFile, _GitFile 

56 

57ConfigKey = Union[str, bytes, tuple[Union[str, bytes], ...]] 

58ConfigValue = Union[str, bytes, bool, int] 

59 

60logger = logging.getLogger(__name__) 

61 

62# Type for file opener callback 

63FileOpener = Callable[[Union[str, os.PathLike]], BinaryIO] 

64 

65# Type for includeIf condition matcher 

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

67ConditionMatcher = Callable[[str], bool] 

68 

69# Security limits for include files 

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

71DEFAULT_MAX_INCLUDE_DEPTH = 10 # Maximum recursion depth for includes 

72 

73 

74def _match_gitdir_pattern( 

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

76) -> bool: 

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

78 

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

80 """ 

81 # Convert to strings for easier manipulation 

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

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

84 

85 # Normalize paths to use forward slashes for consistent matching 

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

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

88 

89 if ignorecase: 

90 path_str = path_str.lower() 

91 pattern_str = pattern_str.lower() 

92 

93 # Handle the common cases for gitdir patterns 

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

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

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

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

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

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

100 # Pattern like **/filename 

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

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

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

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

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

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

107 elif "**" in pattern_str: 

108 # Handle patterns with ** in the middle 

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

110 if len(parts) == 2: 

111 prefix, suffix = parts 

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

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

114 return False 

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

116 return False 

117 return True 

118 

119 # Direct match or simple glob pattern 

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

121 import fnmatch 

122 

123 return fnmatch.fnmatch(path_str, pattern_str) 

124 else: 

125 return path_str == pattern_str 

126 

127 

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

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

130 

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

132 

133 Raises: 

134 ValueError: If the pattern is invalid 

135 """ 

136 # Convert glob pattern to regex 

137 pattern_escaped = re.escape(pattern) 

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

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

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

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

142 # Anchor the pattern 

143 pattern_regex = f"^{pattern_escaped}$" 

144 

145 try: 

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

147 except re.error as e: 

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

149 

150 

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

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

153 return key.lower() 

154 

155 if isinstance(key, tuple): 

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

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

158 if len(key) > 0: 

159 first = key[0] 

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

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

162 return key 

163 

164 raise TypeError(key) 

165 

166 

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

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

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

170 

171 

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

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

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

175 self._keyed: dict[Any, V] = {} 

176 self._default_factory = default_factory 

177 

178 @classmethod 

179 def make( 

180 cls, 

181 dict_in: Optional[ 

182 Union[MutableMapping[K, V], "CaseInsensitiveOrderedMultiDict[K, V]"] 

183 ] = None, 

184 default_factory: Optional[Callable[[], V]] = None, 

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

186 if isinstance(dict_in, cls): 

187 return dict_in 

188 

189 out = cls(default_factory=default_factory) 

190 

191 if dict_in is None: 

192 return out 

193 

194 if not isinstance(dict_in, MutableMapping): 

195 raise TypeError 

196 

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

198 out[key] = value 

199 

200 return out 

201 

202 def __len__(self) -> int: 

203 return len(self._keyed) 

204 

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

206 return self._keyed.keys() # type: ignore[return-value] 

207 

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

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

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

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

212 self._mapping = mapping 

213 

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

215 return iter(self._mapping._real) 

216 

217 def __len__(self) -> int: 

218 return len(self._mapping._real) 

219 

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

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

222 return False 

223 key, value = item 

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

225 

226 return OrderedItemsView(self) 

227 

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

229 return iter(self._keyed) 

230 

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

232 return self._keyed.values() 

233 

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

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

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

237 

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

239 # This method replaces all existing values for the key 

240 lower = lower_key(key) 

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

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

243 self._keyed[lower] = value 

244 

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

246 lower_k = lower_key(key) 

247 del self._keyed[lower_k] 

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

249 if lower_key(actual) == lower_k: 

250 del self._real[i] 

251 

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

253 return self._keyed[lower_key(item)] 

254 

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

256 try: 

257 return self[key] 

258 except KeyError: 

259 if default is not None: 

260 return default 

261 elif self._default_factory is not None: 

262 return self._default_factory() 

263 else: 

264 return None 

265 

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

267 lowered_key = lower_key(key) 

268 for actual, value in self._real: 

269 if lower_key(actual) == lowered_key: 

270 yield value 

271 

272 def setdefault(self, key: K, default: Optional[V] = None) -> V: 

273 try: 

274 return self[key] 

275 except KeyError: 

276 if default is not None: 

277 self[key] = default 

278 return default 

279 elif self._default_factory is not None: 

280 value = self._default_factory() 

281 self[key] = value 

282 return value 

283 else: 

284 raise 

285 

286 

287Name = bytes 

288NameLike = Union[bytes, str] 

289Section = tuple[bytes, ...] 

290SectionLike = Union[bytes, str, tuple[Union[bytes, str], ...]] 

291Value = bytes 

292ValueLike = Union[bytes, str] 

293 

294 

295class Config: 

296 """A Git configuration.""" 

297 

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

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

300 

301 Args: 

302 section: Tuple with section name and optional subsection name 

303 name: Variable name 

304 Returns: 

305 Contents of the setting 

306 Raises: 

307 KeyError: if the value is not set 

308 """ 

309 raise NotImplementedError(self.get) 

310 

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

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

313 

314 Args: 

315 section: Tuple with section name and optional subsection namee 

316 name: Variable name 

317 Returns: 

318 Contents of the setting as iterable 

319 Raises: 

320 KeyError: if the value is not set 

321 """ 

322 raise NotImplementedError(self.get_multivar) 

323 

324 @overload 

325 def get_boolean( 

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

327 ) -> bool: ... 

328 

329 @overload 

330 def get_boolean(self, section: SectionLike, name: NameLike) -> Optional[bool]: ... 

331 

332 def get_boolean( 

333 self, section: SectionLike, name: NameLike, default: Optional[bool] = None 

334 ) -> Optional[bool]: 

335 """Retrieve a configuration setting as boolean. 

336 

337 Args: 

338 section: Tuple with section name and optional subsection name 

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

340 subsection. 

341 

342 Returns: 

343 Contents of the setting 

344 """ 

345 try: 

346 value = self.get(section, name) 

347 except KeyError: 

348 return default 

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

350 return True 

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

352 return False 

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

354 

355 def set( 

356 self, section: SectionLike, name: NameLike, value: Union[ValueLike, bool] 

357 ) -> None: 

358 """Set a configuration value. 

359 

360 Args: 

361 section: Tuple with section name and optional subsection namee 

362 name: Name of the configuration value, including section 

363 and optional subsection 

364 value: value of the setting 

365 """ 

366 raise NotImplementedError(self.set) 

367 

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

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

370 

371 Args: 

372 section: Tuple with section name and optional subsection namee 

373 Returns: 

374 Iterator over (name, value) pairs 

375 """ 

376 raise NotImplementedError(self.items) 

377 

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

379 """Iterate over the sections. 

380 

381 Returns: Iterator over section tuples 

382 """ 

383 raise NotImplementedError(self.sections) 

384 

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

386 """Check if a specified section exists. 

387 

388 Args: 

389 name: Name of section to check for 

390 Returns: 

391 boolean indicating whether the section exists 

392 """ 

393 return name in self.sections() 

394 

395 

396class ConfigDict(Config): 

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

398 

399 def __init__( 

400 self, 

401 values: Union[ 

402 MutableMapping[Section, CaseInsensitiveOrderedMultiDict[Name, Value]], None 

403 ] = None, 

404 encoding: Union[str, None] = None, 

405 ) -> None: 

406 """Create a new ConfigDict.""" 

407 if encoding is None: 

408 encoding = sys.getdefaultencoding() 

409 self.encoding = encoding 

410 self._values: CaseInsensitiveOrderedMultiDict[ 

411 Section, CaseInsensitiveOrderedMultiDict[Name, Value] 

412 ] = CaseInsensitiveOrderedMultiDict.make( 

413 values, default_factory=CaseInsensitiveOrderedMultiDict 

414 ) 

415 

416 def __repr__(self) -> str: 

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

418 

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

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

421 

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

423 return self._values.__getitem__(key) 

424 

425 def __setitem__( 

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

427 ) -> None: 

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

429 

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

431 return self._values.__delitem__(key) 

432 

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

434 return self._values.__iter__() 

435 

436 def __len__(self) -> int: 

437 return self._values.__len__() 

438 

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

440 return self._values.keys() 

441 

442 @classmethod 

443 def _parse_setting(cls, name: str) -> tuple[str, Optional[str], str]: 

444 parts = name.split(".") 

445 if len(parts) == 3: 

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

447 else: 

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

449 

450 def _check_section_and_name( 

451 self, section: SectionLike, name: NameLike 

452 ) -> tuple[Section, Name]: 

453 if not isinstance(section, tuple): 

454 section = (section,) 

455 

456 checked_section = tuple( 

457 [ 

458 subsection.encode(self.encoding) 

459 if not isinstance(subsection, bytes) 

460 else subsection 

461 for subsection in section 

462 ] 

463 ) 

464 

465 if not isinstance(name, bytes): 

466 name = name.encode(self.encoding) 

467 

468 return checked_section, name 

469 

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

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

472 

473 if len(section) > 1: 

474 try: 

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

476 except KeyError: 

477 pass 

478 

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

480 

481 def get( 

482 self, 

483 section: SectionLike, 

484 name: NameLike, 

485 ) -> Value: 

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

487 

488 if len(section) > 1: 

489 try: 

490 return self._values[section][name] 

491 except KeyError: 

492 pass 

493 

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

495 

496 def set( 

497 self, 

498 section: SectionLike, 

499 name: NameLike, 

500 value: Union[ValueLike, bool], 

501 ) -> None: 

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

503 

504 if isinstance(value, bool): 

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

506 

507 if not isinstance(value, bytes): 

508 value = value.encode(self.encoding) 

509 

510 section_dict = self._values.setdefault(section) 

511 if hasattr(section_dict, "set"): 

512 section_dict.set(name, value) 

513 else: 

514 section_dict[name] = value 

515 

516 def add( 

517 self, 

518 section: SectionLike, 

519 name: NameLike, 

520 value: Union[ValueLike, bool], 

521 ) -> None: 

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

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

524 

525 if isinstance(value, bool): 

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

527 

528 if not isinstance(value, bytes): 

529 value = value.encode(self.encoding) 

530 

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

532 

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

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

535 section_dict = self._values.get(section_bytes) 

536 if section_dict is not None: 

537 return iter(section_dict.items()) 

538 return iter([]) 

539 

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

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

542 

543 

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

545 if ( 

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

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

548 or b"#" in value 

549 ): 

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

551 else: 

552 return _escape_value(value) 

553 

554 

555_ESCAPE_TABLE = { 

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

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

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

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

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

561} 

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

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

564 

565 

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

567 value = bytearray(value.strip()) 

568 ret = bytearray() 

569 whitespace = bytearray() 

570 in_quotes = False 

571 i = 0 

572 while i < len(value): 

573 c = value[i] 

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

575 i += 1 

576 if i >= len(value): 

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

578 if whitespace: 

579 ret.extend(whitespace) 

580 whitespace = bytearray() 

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

582 else: 

583 try: 

584 v = _ESCAPE_TABLE[value[i]] 

585 if whitespace: 

586 ret.extend(whitespace) 

587 whitespace = bytearray() 

588 ret.append(v) 

589 except KeyError: 

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

591 if whitespace: 

592 ret.extend(whitespace) 

593 whitespace = bytearray() 

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

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

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

597 in_quotes = not in_quotes 

598 elif c in _COMMENT_CHARS and not in_quotes: 

599 # the rest of the line is a comment 

600 break 

601 elif c in _WHITESPACE_CHARS: 

602 whitespace.append(c) 

603 else: 

604 if whitespace: 

605 ret.extend(whitespace) 

606 whitespace = bytearray() 

607 ret.append(c) 

608 i += 1 

609 

610 if in_quotes: 

611 raise ValueError("missing end quote") 

612 

613 return bytes(ret) 

614 

615 

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

617 """Escape a value.""" 

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

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

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

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

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

623 return value 

624 

625 

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

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

628 c = name[i : i + 1] 

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

630 return False 

631 return True 

632 

633 

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

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

636 c = name[i : i + 1] 

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

638 return False 

639 return True 

640 

641 

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

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

644 quote = ord(b'"') 

645 string_open = False 

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

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

648 # Comment characters outside balanced quotes denote comment start 

649 if character == quote: 

650 string_open = not string_open 

651 elif not string_open and character in comment_bytes: 

652 return line[:i] 

653 return line 

654 

655 

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

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

658 

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

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

661 2. Not within quotes 

662 

663 Args: 

664 value: The value to check 

665 

666 Returns: 

667 True if the value ends with a line continuation backslash 

668 """ 

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

670 return False 

671 

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

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

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

675 else: 

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

677 

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

679 return False 

680 

681 # Count consecutive backslashes at the end 

682 backslash_count = 0 

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

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

685 backslash_count += 1 

686 else: 

687 break 

688 

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

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

691 return backslash_count % 2 == 1 

692 

693 

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

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

696 line = _strip_comments(line).rstrip() 

697 in_quotes = False 

698 escaped = False 

699 for i, c in enumerate(line): 

700 if escaped: 

701 escaped = False 

702 continue 

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

704 in_quotes = not in_quotes 

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

706 escaped = True 

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

708 last = i 

709 break 

710 else: 

711 raise ValueError("expected trailing ]") 

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

713 line = line[last + 1 :] 

714 section: Section 

715 if len(pts) == 2: 

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

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

718 # Standard quoted subsection 

719 pts[1] = pts[1][1:-1] 

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

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

722 # Git allows these without strict quote validation 

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

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

725 pts[1] = pts[1][1:-1] 

726 else: 

727 # Other sections must have quoted subsections 

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

729 if not _check_section_name(pts[0]): 

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

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

732 else: 

733 if not _check_section_name(pts[0]): 

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

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

736 if len(pts) == 2: 

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

738 else: 

739 section = (pts[0],) 

740 return section, line 

741 

742 

743class ConfigFile(ConfigDict): 

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

745 

746 def __init__( 

747 self, 

748 values: Union[ 

749 MutableMapping[Section, CaseInsensitiveOrderedMultiDict[Name, Value]], None 

750 ] = None, 

751 encoding: Union[str, None] = None, 

752 ) -> None: 

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

754 self.path: Optional[str] = None 

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

756 

757 @classmethod 

758 def from_file( 

759 cls, 

760 f: BinaryIO, 

761 *, 

762 config_dir: Optional[str] = None, 

763 included_paths: Optional[set[str]] = None, 

764 include_depth: int = 0, 

765 max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH, 

766 file_opener: Optional[FileOpener] = None, 

767 condition_matchers: Optional[dict[str, ConditionMatcher]] = None, 

768 ) -> "ConfigFile": 

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

770 

771 Args: 

772 f: File-like object to read from 

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

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

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

776 max_include_depth: Maximum allowed include depth 

777 file_opener: Optional callback to open included files 

778 condition_matchers: Optional dict of condition matchers for includeIf 

779 """ 

780 if include_depth > max_include_depth: 

781 # Prevent excessive recursion 

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

783 

784 ret = cls() 

785 if included_paths is not None: 

786 ret._included_paths = included_paths.copy() 

787 

788 section: Optional[Section] = None 

789 setting = None 

790 continuation = None 

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

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

793 line = line[3:] 

794 line = line.lstrip() 

795 if setting is None: 

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

797 section, line = _parse_section_header_line(line) 

798 ret._values.setdefault(section) 

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

800 continue 

801 if section is None: 

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

803 try: 

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

805 except ValueError: 

806 setting = line 

807 value = b"true" 

808 setting = setting.strip() 

809 if not _check_variable_name(setting): 

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

811 if _is_line_continuation(value): 

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

813 continuation = value[:-3] 

814 else: 

815 continuation = value[:-2] 

816 else: 

817 continuation = None 

818 value = _parse_string(value) 

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

820 

821 # Process include/includeIf directives 

822 ret._handle_include_directive( 

823 section, 

824 setting, 

825 value, 

826 config_dir=config_dir, 

827 include_depth=include_depth, 

828 max_include_depth=max_include_depth, 

829 file_opener=file_opener, 

830 condition_matchers=condition_matchers, 

831 ) 

832 

833 setting = None 

834 else: # continuation line 

835 assert continuation is not None 

836 if _is_line_continuation(line): 

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

838 continuation += line[:-3] 

839 else: 

840 continuation += line[:-2] 

841 else: 

842 continuation += line 

843 value = _parse_string(continuation) 

844 assert section is not None # Already checked above 

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

846 

847 # Process include/includeIf directives 

848 ret._handle_include_directive( 

849 section, 

850 setting, 

851 value, 

852 config_dir=config_dir, 

853 include_depth=include_depth, 

854 max_include_depth=max_include_depth, 

855 file_opener=file_opener, 

856 condition_matchers=condition_matchers, 

857 ) 

858 

859 continuation = None 

860 setting = None 

861 return ret 

862 

863 def _handle_include_directive( 

864 self, 

865 section: Optional[Section], 

866 setting: bytes, 

867 value: bytes, 

868 *, 

869 config_dir: Optional[str], 

870 include_depth: int, 

871 max_include_depth: int, 

872 file_opener: Optional[FileOpener], 

873 condition_matchers: Optional[dict[str, ConditionMatcher]], 

874 ) -> None: 

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

876 if ( 

877 section is not None 

878 and setting == b"path" 

879 and ( 

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

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

882 ) 

883 ): 

884 self._process_include( 

885 section, 

886 value, 

887 config_dir=config_dir, 

888 include_depth=include_depth, 

889 max_include_depth=max_include_depth, 

890 file_opener=file_opener, 

891 condition_matchers=condition_matchers, 

892 ) 

893 

894 def _process_include( 

895 self, 

896 section: Section, 

897 path_value: bytes, 

898 *, 

899 config_dir: Optional[str], 

900 include_depth: int, 

901 max_include_depth: int, 

902 file_opener: Optional[FileOpener], 

903 condition_matchers: Optional[dict[str, ConditionMatcher]], 

904 ) -> None: 

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

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

907 

908 # Handle includeIf conditions 

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

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

911 if not self._evaluate_includeif_condition( 

912 condition, config_dir, condition_matchers 

913 ): 

914 return 

915 

916 # Resolve the include path 

917 include_path = self._resolve_include_path(path_str, config_dir) 

918 if not include_path: 

919 return 

920 

921 # Check for circular includes 

922 try: 

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

924 except (OSError, ValueError) as e: 

925 # Invalid path - log and skip 

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

927 return 

928 if abs_path in self._included_paths: 

929 return 

930 

931 # Load and merge the included file 

932 try: 

933 # Use provided file opener or default to GitFile 

934 opener: FileOpener 

935 if file_opener is None: 

936 

937 def opener(path: Union[str, os.PathLike]) -> BinaryIO: 

938 return cast(BinaryIO, GitFile(path, "rb")) 

939 else: 

940 opener = file_opener 

941 

942 f = opener(include_path) 

943 except (OSError, ValueError) as e: 

944 # Git silently ignores missing or unreadable include files 

945 # Log for debugging purposes 

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

947 else: 

948 with f as included_file: 

949 # Track this path to prevent cycles 

950 self._included_paths.add(abs_path) 

951 

952 # Parse the included file 

953 included_config = ConfigFile.from_file( 

954 included_file, 

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

956 included_paths=self._included_paths, 

957 include_depth=include_depth + 1, 

958 max_include_depth=max_include_depth, 

959 file_opener=file_opener, 

960 condition_matchers=condition_matchers, 

961 ) 

962 

963 # Merge the included configuration 

964 self._merge_config(included_config) 

965 

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

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

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

969 if section not in self._values: 

970 self._values[section] = CaseInsensitiveOrderedMultiDict() 

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

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

973 

974 def _resolve_include_path( 

975 self, path: str, config_dir: Optional[str] 

976 ) -> Optional[str]: 

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

978 # Expand ~ to home directory 

979 path = os.path.expanduser(path) 

980 

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

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

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

984 

985 return path 

986 

987 def _evaluate_includeif_condition( 

988 self, 

989 condition: str, 

990 config_dir: Optional[str] = None, 

991 condition_matchers: Optional[dict[str, ConditionMatcher]] = None, 

992 ) -> bool: 

993 """Evaluate an includeIf condition.""" 

994 # Try custom matchers first if provided 

995 if condition_matchers: 

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

997 if condition.startswith(prefix): 

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

999 

1000 # Fall back to built-in matchers 

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

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

1003 else: 

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

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

1006 return False 

1007 

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

1009 """Evaluate a hasconfig condition. 

1010 

1011 Format: hasconfig:config.key:pattern 

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

1013 """ 

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

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

1016 if len(parts) != 2: 

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

1018 return False 

1019 

1020 config_key, pattern = parts 

1021 

1022 # Parse the config key to get section and name 

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

1024 if len(key_parts) < 2: 

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

1026 return False 

1027 

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

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

1030 # Match any subsection 

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

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

1033 

1034 # Check all sections that match the pattern 

1035 for section in self.sections(): 

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

1037 try: 

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

1039 for value in values: 

1040 if self._match_hasconfig_pattern(value, pattern): 

1041 return True 

1042 except KeyError: 

1043 continue 

1044 else: 

1045 # Direct section lookup 

1046 if len(key_parts) == 2: 

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

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

1049 else: 

1050 section = ( 

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

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

1053 ) 

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

1055 

1056 try: 

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

1058 for value in values: 

1059 if self._match_hasconfig_pattern(value, pattern): 

1060 return True 

1061 except KeyError: 

1062 pass 

1063 

1064 return False 

1065 

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

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

1068 

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

1070 """ 

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

1072 return match_glob_pattern(value_str, pattern) 

1073 

1074 @classmethod 

1075 def from_path( 

1076 cls, 

1077 path: Union[str, os.PathLike], 

1078 *, 

1079 max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH, 

1080 file_opener: Optional[FileOpener] = None, 

1081 condition_matchers: Optional[dict[str, ConditionMatcher]] = None, 

1082 ) -> "ConfigFile": 

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

1084 

1085 Args: 

1086 path: Path to the configuration file 

1087 max_include_depth: Maximum allowed include depth 

1088 file_opener: Optional callback to open included files 

1089 condition_matchers: Optional dict of condition matchers for includeIf 

1090 """ 

1091 abs_path = os.fspath(path) 

1092 config_dir = os.path.dirname(abs_path) 

1093 

1094 # Use provided file opener or default to GitFile 

1095 opener: FileOpener 

1096 if file_opener is None: 

1097 

1098 def opener(p: Union[str, os.PathLike]) -> BinaryIO: 

1099 return cast(BinaryIO, GitFile(p, "rb")) 

1100 else: 

1101 opener = file_opener 

1102 

1103 with opener(abs_path) as f: 

1104 ret = cls.from_file( 

1105 f, 

1106 config_dir=config_dir, 

1107 max_include_depth=max_include_depth, 

1108 file_opener=file_opener, 

1109 condition_matchers=condition_matchers, 

1110 ) 

1111 ret.path = abs_path 

1112 return ret 

1113 

1114 def write_to_path(self, path: Optional[Union[str, os.PathLike]] = None) -> None: 

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

1116 if path is None: 

1117 if self.path is None: 

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

1119 path_to_use: Union[str, os.PathLike] = self.path 

1120 else: 

1121 path_to_use = path 

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

1123 self.write_to_file(f) 

1124 

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

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

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

1128 try: 

1129 section_name, subsection_name = section 

1130 except ValueError: 

1131 (section_name,) = section 

1132 subsection_name = None 

1133 if subsection_name is None: 

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

1135 else: 

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

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

1138 value = _format_string(value) 

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

1140 

1141 

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

1143 xdg_config_home = os.environ.get( 

1144 "XDG_CONFIG_HOME", 

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

1146 ) 

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

1148 

1149 

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

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

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

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

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

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

1156 # 

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

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

1159 yield git_dir 

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

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

1162 yield parent_dir 

1163 break 

1164 

1165 

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

1167 import platform 

1168 import winreg 

1169 

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

1171 subkey = ( 

1172 "SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\" 

1173 "CurrentVersion\\Uninstall\\Git_is1" 

1174 ) 

1175 else: 

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

1177 

1178 for key in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE): # type: ignore 

1179 with suppress(OSError): 

1180 with winreg.OpenKey(key, subkey) as k: # type: ignore 

1181 val, typ = winreg.QueryValueEx(k, "InstallLocation") # type: ignore 

1182 if typ == winreg.REG_SZ: # type: ignore 

1183 yield val 

1184 

1185 

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

1187# following: 

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

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

1190# system registry 

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

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

1193 

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

1195 """ 

1196 # Try to find Git installation from PATH first 

1197 for git_dir in _find_git_in_win_path(): 

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

1199 return # Only use the first found path 

1200 

1201 # Fall back to registry if not found in PATH 

1202 for git_dir in _find_git_in_win_reg(): 

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

1204 return # Only use the first found path 

1205 

1206 

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

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

1209 

1210 Returns all possible config paths including deprecated locations. 

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

1212 """ 

1213 # Include deprecated PROGRAMDATA location 

1214 if "PROGRAMDATA" in os.environ: 

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

1216 

1217 # Include all Git installations found 

1218 for git_dir in _find_git_in_win_path(): 

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

1220 for git_dir in _find_git_in_win_reg(): 

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

1222 

1223 

1224class StackedConfig(Config): 

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

1226 

1227 def __init__( 

1228 self, backends: list[ConfigFile], writable: Optional[ConfigFile] = None 

1229 ) -> None: 

1230 self.backends = backends 

1231 self.writable = writable 

1232 

1233 def __repr__(self) -> str: 

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

1235 

1236 @classmethod 

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

1238 return cls(cls.default_backends()) 

1239 

1240 @classmethod 

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

1242 """Retrieve the default configuration. 

1243 

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

1245 """ 

1246 paths = [] 

1247 

1248 # Handle GIT_CONFIG_GLOBAL - overrides user config paths 

1249 try: 

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

1251 except KeyError: 

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

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

1254 

1255 # Handle GIT_CONFIG_SYSTEM and GIT_CONFIG_NOSYSTEM 

1256 try: 

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

1258 except KeyError: 

1259 if "GIT_CONFIG_NOSYSTEM" not in os.environ: 

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

1261 if sys.platform == "win32": 

1262 paths.extend(get_win_system_paths()) 

1263 

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

1265 

1266 backends = [] 

1267 for path in paths: 

1268 try: 

1269 cf = ConfigFile.from_path(path) 

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

1271 except FileNotFoundError: 

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

1273 continue 

1274 backends.append(cf) 

1275 return backends 

1276 

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

1278 if not isinstance(section, tuple): 

1279 section = (section,) 

1280 for backend in self.backends: 

1281 try: 

1282 return backend.get(section, name) 

1283 except KeyError: 

1284 pass 

1285 raise KeyError(name) 

1286 

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

1288 if not isinstance(section, tuple): 

1289 section = (section,) 

1290 for backend in self.backends: 

1291 try: 

1292 yield from backend.get_multivar(section, name) 

1293 except KeyError: 

1294 pass 

1295 

1296 def set( 

1297 self, section: SectionLike, name: NameLike, value: Union[ValueLike, bool] 

1298 ) -> None: 

1299 if self.writable is None: 

1300 raise NotImplementedError(self.set) 

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

1302 

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

1304 seen = set() 

1305 for backend in self.backends: 

1306 for section in backend.sections(): 

1307 if section not in seen: 

1308 seen.add(section) 

1309 yield section 

1310 

1311 

1312def read_submodules( 

1313 path: Union[str, os.PathLike], 

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

1315 """Read a .gitmodules file.""" 

1316 cfg = ConfigFile.from_path(path) 

1317 return parse_submodules(cfg) 

1318 

1319 

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

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

1322 

1323 Args: 

1324 config: A `ConfigFile` 

1325 Returns: 

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

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

1328 """ 

1329 for section in config.sections(): 

1330 section_kind, section_name = section 

1331 if section_kind == b"submodule": 

1332 try: 

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

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

1335 yield (sm_path, sm_url, section_name) 

1336 except KeyError: 

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

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

1339 # how git itself handles malformed .gitmodule entries. 

1340 pass 

1341 

1342 

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

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

1345 for section in config.sections(): 

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

1347 continue 

1348 replacement = section[1] 

1349 try: 

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

1351 except KeyError: 

1352 needles = [] 

1353 if push: 

1354 try: 

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

1356 except KeyError: 

1357 pass 

1358 for needle in needles: 

1359 assert isinstance(needle, bytes) 

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

1361 

1362 

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

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

1365 longest_needle = "" 

1366 updated_url = orig_url 

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

1368 if not orig_url.startswith(needle): 

1369 continue 

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

1371 longest_needle = needle 

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

1373 return updated_url