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

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

1076 statements  

1# objects.py -- Access to base git objects 

2# Copyright (C) 2007 James Westby <jw+debian@jameswestby.net> 

3# Copyright (C) 2008-2013 Jelmer Vernooij <jelmer@jelmer.uk> 

4# 

5# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later 

6# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU 

7# General Public License as published by the Free Software Foundation; version 2.0 

8# or (at your option) any later version. You can redistribute it and/or 

9# modify it under the terms of either of these two licenses. 

10# 

11# Unless required by applicable law or agreed to in writing, software 

12# distributed under the License is distributed on an "AS IS" BASIS, 

13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

14# See the License for the specific language governing permissions and 

15# limitations under the License. 

16# 

17# You should have received a copy of the licenses; if not, see 

18# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License 

19# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache 

20# License, Version 2.0. 

21# 

22 

23"""Access to base git objects.""" 

24 

25__all__ = [ 

26 "BEGIN_PGP_SIGNATURE", 

27 "BEGIN_SSH_SIGNATURE", 

28 "MAX_TIME", 

29 "OBJECT_CLASSES", 

30 "SIGNATURE_PGP", 

31 "SIGNATURE_SSH", 

32 "S_IFGITLINK", 

33 "S_ISGITLINK", 

34 "ZERO_SHA", 

35 "Blob", 

36 "Commit", 

37 "EmptyFileException", 

38 "FixedSha", 

39 "ObjectID", 

40 "RawObjectID", 

41 "ShaFile", 

42 "SubmoduleEncountered", 

43 "Tag", 

44 "Tree", 

45 "TreeEntry", 

46 "check_hexsha", 

47 "check_identity", 

48 "check_time", 

49 "filename_to_hex", 

50 "format_time_entry", 

51 "format_timezone", 

52 "git_line", 

53 "hex_to_filename", 

54 "hex_to_sha", 

55 "is_blob", 

56 "is_commit", 

57 "is_tag", 

58 "is_tree", 

59 "key_entry", 

60 "key_entry_name_order", 

61 "object_class", 

62 "object_header", 

63 "parse_commit_broken", 

64 "parse_tree", 

65 "pretty_format_tree_entry", 

66 "serializable_property", 

67 "serialize_tree", 

68 "sha_to_hex", 

69 "sorted_tree_items", 

70 "valid_hexsha", 

71] 

72 

73import binascii 

74import os 

75import posixpath 

76import re 

77import stat 

78import sys 

79import zlib 

80from collections.abc import Callable, Iterable, Iterator, Sequence 

81from hashlib import sha1 

82from io import BufferedIOBase, BytesIO 

83from typing import ( 

84 IO, 

85 TYPE_CHECKING, 

86 NamedTuple, 

87 TypeVar, 

88) 

89 

90if sys.version_info >= (3, 11): 

91 from typing import Self 

92else: 

93 from typing_extensions import Self 

94 

95from typing import NewType, TypeGuard 

96 

97from .errors import ( 

98 ChecksumMismatch, 

99 FileFormatException, 

100 NotBlobError, 

101 NotCommitError, 

102 NotTagError, 

103 NotTreeError, 

104 ObjectFormatException, 

105) 

106from .file import GitFile 

107from .object_format import DEFAULT_OBJECT_FORMAT, ObjectFormat 

108 

109if TYPE_CHECKING: 

110 from _hashlib import HASH 

111 

112 from .file import _GitFile 

113 

114# Zero SHA constants for backward compatibility - now defined below as ObjectID 

115 

116 

117# Header fields for commits 

118_TREE_HEADER = b"tree" 

119_PARENT_HEADER = b"parent" 

120_AUTHOR_HEADER = b"author" 

121_COMMITTER_HEADER = b"committer" 

122_ENCODING_HEADER = b"encoding" 

123_MERGETAG_HEADER = b"mergetag" 

124_GPGSIG_HEADER = b"gpgsig" 

125 

126# Header fields for objects 

127_OBJECT_HEADER = b"object" 

128_TYPE_HEADER = b"type" 

129_TAG_HEADER = b"tag" 

130_TAGGER_HEADER = b"tagger" 

131 

132 

133S_IFGITLINK = 0o160000 

134 

135# Intentionally flexible regex to support various types of brokenness 

136# in commit/tag author/committer/tagger lines 

137_TIME_ENTRY_RE = re.compile( 

138 b"^(?P<person>.*) (?P<time>-?[0-9]+) (?P<timezone>[+-]{0,2}[0-9]+)$" 

139) 

140 

141 

142MAX_TIME = 9223372036854775807 # (2**63) - 1 - signed long int max 

143 

144BEGIN_PGP_SIGNATURE = b"-----BEGIN PGP SIGNATURE-----" 

145BEGIN_SSH_SIGNATURE = b"-----BEGIN SSH SIGNATURE-----" 

146 

147# Signature type constants 

148SIGNATURE_PGP = b"pgp" 

149SIGNATURE_SSH = b"ssh" 

150 

151 

152# Hex SHA type 

153ObjectID = NewType("ObjectID", bytes) 

154 

155# Raw SHA type 

156RawObjectID = NewType("RawObjectID", bytes) 

157 

158# Zero SHA constant 

159ZERO_SHA: ObjectID = ObjectID(b"0" * 40) 

160 

161 

162class EmptyFileException(FileFormatException): 

163 """An unexpectedly empty file was encountered.""" 

164 

165 

166def S_ISGITLINK(m: int) -> bool: 

167 """Check if a mode indicates a submodule. 

168 

169 Args: 

170 m: Mode to check 

171 Returns: a ``boolean`` 

172 """ 

173 return stat.S_IFMT(m) == S_IFGITLINK 

174 

175 

176# Git's core.bigFileThreshold default. 

177DEFAULT_LOOSE_OBJECT_SIZE_LIMIT = 512 * 1024 * 1024 

178 

179 

180def _decompress( 

181 string: bytes, max_size: int = DEFAULT_LOOSE_OBJECT_SIZE_LIMIT 

182) -> bytes: 

183 """Decompress a zlib-compressed loose object, bounded by max_size. 

184 

185 Raises: 

186 zlib.error: if the decompressed data would exceed max_size bytes, 

187 or on any other decompression error. 

188 """ 

189 dcomp = zlib.decompressobj() 

190 # +1 so overrun surfaces as unconsumed_tail rather than being truncated. 

191 dcomped = dcomp.decompress(string, max_size + 1) 

192 if dcomp.unconsumed_tail: 

193 raise zlib.error("decompressed data exceeds maximum size") 

194 dcomped += dcomp.flush() 

195 if len(dcomped) > max_size: 

196 raise zlib.error("decompressed data exceeds maximum size") 

197 return dcomped 

198 

199 

200def sha_to_hex(sha: RawObjectID) -> ObjectID: 

201 """Takes a string and returns the hex of the sha within.""" 

202 hexsha = binascii.hexlify(sha) 

203 # Support both SHA1 (40 chars) and SHA256 (64 chars) 

204 if len(hexsha) not in (40, 64): 

205 raise ValueError(f"Incorrect length of sha string: {hexsha!r}") 

206 return ObjectID(hexsha) 

207 

208 

209def hex_to_sha(hex: ObjectID | str) -> RawObjectID: 

210 """Takes a hex sha and returns a binary sha.""" 

211 # Support both SHA1 (40 chars) and SHA256 (64 chars) 

212 if len(hex) not in (40, 64): 

213 raise ValueError(f"Incorrect length of hexsha: {hex!r}") 

214 try: 

215 return RawObjectID(binascii.unhexlify(hex)) 

216 except TypeError as exc: 

217 if not isinstance(hex, bytes): 

218 raise 

219 raise ValueError(exc.args[0]) from exc 

220 

221 

222def valid_hexsha(hex: bytes | str) -> bool: 

223 """Check if a hex string is a valid SHA1 or SHA256. 

224 

225 Args: 

226 hex: Hex string to validate 

227 

228 Returns: 

229 True if valid SHA1 (40 chars) or SHA256 (64 chars), False otherwise 

230 """ 

231 if len(hex) not in (40, 64): 

232 return False 

233 try: 

234 binascii.unhexlify(hex) 

235 except (TypeError, binascii.Error): 

236 return False 

237 else: 

238 return True 

239 

240 

241PathT = TypeVar("PathT", str, bytes) 

242 

243 

244def hex_to_filename(path: PathT, hex: ObjectID | str) -> PathT: 

245 """Takes a hex sha and returns its filename relative to the given path.""" 

246 # os.path.join accepts bytes or unicode, but all args must be of the same 

247 # type. Make sure that hex which is expected to be bytes, is the same type 

248 # as path. 

249 if isinstance(path, str): 

250 if isinstance(hex, bytes): 

251 hex_str = hex.decode("ascii") 

252 else: 

253 hex_str = hex 

254 dir_name = hex_str[:2] 

255 file_name = hex_str[2:] 

256 result = os.path.join(path, dir_name, file_name) 

257 assert isinstance(result, str) 

258 return result 

259 else: 

260 # path is bytes 

261 if isinstance(hex, str): 

262 hex_bytes = hex.encode("ascii") 

263 else: 

264 hex_bytes = hex 

265 dir_name_b = hex_bytes[:2] 

266 file_name_b = hex_bytes[2:] 

267 result_b = os.path.join(path, dir_name_b, file_name_b) 

268 assert isinstance(result_b, bytes) 

269 return result_b 

270 

271 

272def filename_to_hex(filename: str | bytes) -> str: 

273 """Takes an object filename and returns its corresponding hex sha.""" 

274 import warnings 

275 

276 warnings.warn( 

277 "filename_to_hex is unused and will be removed in a future version of Dulwich", 

278 DeprecationWarning, 

279 ) 

280 # grab the last (up to) two path components 

281 errmsg = f"Invalid object filename: {filename!r}" 

282 if isinstance(filename, str): 

283 names = filename.rsplit(os.path.sep, 2)[-2:] 

284 assert len(names) == 2, errmsg 

285 base, rest = names 

286 assert len(base) == 2 and len(rest) == 38, errmsg 

287 hex_str = base + rest 

288 hex_bytes = hex_str.encode("ascii") 

289 else: 

290 # filename is bytes 

291 sep = ( 

292 os.path.sep.encode("ascii") if isinstance(os.path.sep, str) else os.path.sep 

293 ) 

294 names_b = filename.rsplit(sep, 2)[-2:] 

295 assert len(names_b) == 2, errmsg 

296 base_b, rest_b = names_b 

297 assert len(base_b) == 2 and len(rest_b) == 38, errmsg 

298 hex_bytes = base_b + rest_b 

299 hex_to_sha(ObjectID(hex_bytes)) 

300 return hex_bytes.decode("ascii") 

301 

302 

303def object_header(num_type: int, length: int) -> bytes: 

304 """Return an object header for the given numeric type and text length.""" 

305 cls = object_class(num_type) 

306 if cls is None: 

307 raise AssertionError(f"unsupported class type num: {num_type}") 

308 return cls.type_name + b" " + str(length).encode("ascii") + b"\0" 

309 

310 

311def serializable_property(name: str, docstring: str | None = None) -> property: 

312 """A property that helps tracking whether serialization is necessary.""" 

313 

314 def set(obj: "ShaFile", value: object) -> None: 

315 """Set the property value and mark the object as needing serialization. 

316 

317 Args: 

318 obj: The ShaFile object 

319 value: The value to set 

320 """ 

321 setattr(obj, "_" + name, value) 

322 obj._needs_serialization = True 

323 

324 def get(obj: "ShaFile") -> object: 

325 """Get the property value. 

326 

327 Args: 

328 obj: The ShaFile object 

329 

330 Returns: 

331 The property value 

332 """ 

333 return getattr(obj, "_" + name) 

334 

335 return property(get, set, doc=docstring) 

336 

337 

338def object_class(type: bytes | int) -> type["ShaFile"] | None: 

339 """Get the object class corresponding to the given type. 

340 

341 Args: 

342 type: Either a type name string or a numeric type. 

343 Returns: The ShaFile subclass corresponding to the given type, or None if 

344 type is not a valid type name/number. 

345 """ 

346 return _TYPE_MAP.get(type, None) 

347 

348 

349def check_hexsha(hex: str | bytes, error_msg: str) -> None: 

350 """Check if a string is a valid hex sha string. 

351 

352 Args: 

353 hex: Hex string to check 

354 error_msg: Error message to use in exception 

355 Raises: 

356 ObjectFormatException: Raised when the string is not valid 

357 """ 

358 if not valid_hexsha(hex): 

359 raise ObjectFormatException(f"{error_msg} {hex!r}") 

360 

361 

362def check_identity(identity: bytes | None, error_msg: str) -> None: 

363 """Check if the specified identity is valid. 

364 

365 This will raise an exception if the identity is not valid. 

366 

367 Args: 

368 identity: Identity string 

369 error_msg: Error message to use in exception 

370 """ 

371 if identity is None: 

372 raise ObjectFormatException(error_msg) 

373 email_start = identity.find(b"<") 

374 email_end = identity.find(b">") 

375 if not all( 

376 [ 

377 email_start >= 1, 

378 identity[email_start - 1] == b" "[0], 

379 identity.find(b"<", email_start + 1) == -1, 

380 email_end == len(identity) - 1, 

381 b"\0" not in identity, 

382 b"\n" not in identity, 

383 ] 

384 ): 

385 raise ObjectFormatException(error_msg) 

386 

387 

388def _path_to_bytes(path: str | bytes) -> bytes: 

389 """Convert a path to bytes for use in error messages.""" 

390 if isinstance(path, str): 

391 return path.encode("utf-8", "surrogateescape") 

392 return path 

393 

394 

395def check_time(time_seconds: int) -> None: 

396 """Check if the specified time is not prone to overflow error. 

397 

398 This will raise an exception if the time is not valid. 

399 

400 Args: 

401 time_seconds: time in seconds 

402 

403 """ 

404 # Prevent overflow error 

405 if time_seconds > MAX_TIME: 

406 raise ObjectFormatException(f"Date field should not exceed {MAX_TIME}") 

407 

408 

409def git_line(*items: bytes) -> bytes: 

410 """Formats items into a space separated line.""" 

411 return b" ".join(items) + b"\n" 

412 

413 

414class FixedSha: 

415 """SHA object that behaves like hashlib's but is given a fixed value.""" 

416 

417 __slots__ = ("_hexsha", "_sha") 

418 

419 def __init__(self, hexsha: str | bytes) -> None: 

420 """Initialize FixedSha with a fixed SHA value. 

421 

422 Args: 

423 hexsha: Hex SHA value as string or bytes 

424 """ 

425 if isinstance(hexsha, str): 

426 hexsha = hexsha.encode("ascii") 

427 if not isinstance(hexsha, bytes): 

428 raise TypeError(f"Expected bytes for hexsha, got {hexsha!r}") 

429 self._hexsha = hexsha 

430 self._sha = hex_to_sha(ObjectID(hexsha)) 

431 

432 def digest(self) -> bytes: 

433 """Return the raw SHA digest.""" 

434 return self._sha 

435 

436 def hexdigest(self) -> str: 

437 """Return the hex SHA digest.""" 

438 return self._hexsha.decode("ascii") 

439 

440 

441# Type guard functions for runtime type narrowing 

442if TYPE_CHECKING: 

443 

444 def is_commit(obj: "ShaFile") -> TypeGuard["Commit"]: 

445 """Check if a ShaFile is a Commit.""" 

446 return obj.type_name == b"commit" 

447 

448 def is_tree(obj: "ShaFile") -> TypeGuard["Tree"]: 

449 """Check if a ShaFile is a Tree.""" 

450 return obj.type_name == b"tree" 

451 

452 def is_blob(obj: "ShaFile") -> TypeGuard["Blob"]: 

453 """Check if a ShaFile is a Blob.""" 

454 return obj.type_name == b"blob" 

455 

456 def is_tag(obj: "ShaFile") -> TypeGuard["Tag"]: 

457 """Check if a ShaFile is a Tag.""" 

458 return obj.type_name == b"tag" 

459else: 

460 # Runtime versions without type narrowing 

461 def is_commit(obj: "ShaFile") -> bool: 

462 """Check if a ShaFile is a Commit.""" 

463 return obj.type_name == b"commit" 

464 

465 def is_tree(obj: "ShaFile") -> bool: 

466 """Check if a ShaFile is a Tree.""" 

467 return obj.type_name == b"tree" 

468 

469 def is_blob(obj: "ShaFile") -> bool: 

470 """Check if a ShaFile is a Blob.""" 

471 return obj.type_name == b"blob" 

472 

473 def is_tag(obj: "ShaFile") -> bool: 

474 """Check if a ShaFile is a Tag.""" 

475 return obj.type_name == b"tag" 

476 

477 

478class ShaFile: 

479 """A git SHA file.""" 

480 

481 __slots__ = ("_chunked_text", "_needs_serialization", "_sha", "object_format") 

482 

483 _needs_serialization: bool 

484 type_name: bytes 

485 type_num: int 

486 _chunked_text: list[bytes] | None 

487 _sha: "FixedSha | None | HASH" 

488 object_format: ObjectFormat 

489 

490 def __init__(self) -> None: 

491 """Initialize a ShaFile.""" 

492 self._sha = None 

493 self._chunked_text = None 

494 self._needs_serialization = True 

495 self.object_format = DEFAULT_OBJECT_FORMAT 

496 

497 @staticmethod 

498 def _parse_legacy_object_header(magic: bytes) -> "ShaFile": 

499 """Parse a legacy object header, creating it but not reading the file.""" 

500 # Legitimate loose object headers are at most a few dozen bytes; a 

501 # larger inflated prefix indicates corruption or a decompression bomb. 

502 header_max = 8192 

503 decomp = zlib.decompressobj() 

504 # Inflate only the header prefix. The object content may be far larger 

505 # than header_max, so we cap the output and keep pulling from 

506 # unconsumed_tail until we find the NUL that terminates the header. 

507 header = decomp.decompress(magic, header_max) 

508 start = 0 

509 end = header.find(b"\0", start) 

510 start = len(header) 

511 while end < 0: 

512 if len(header) >= header_max: 

513 raise zlib.error("object header exceeds maximum size") 

514 if not decomp.unconsumed_tail: 

515 raise ObjectFormatException("Invalid object header, no \\0") 

516 header += decomp.decompress( 

517 decomp.unconsumed_tail, header_max - len(header) 

518 ) 

519 end = header.find(b"\0", start) 

520 start = len(header) 

521 header = header[:end] 

522 type_name, size = header.split(b" ", 1) 

523 try: 

524 int(size) # sanity check 

525 except ValueError as exc: 

526 raise ObjectFormatException(f"Object size not an integer: {exc}") from exc 

527 obj_class = object_class(type_name) 

528 if not obj_class: 

529 raise ObjectFormatException( 

530 "Not a known type: {}".format(type_name.decode("ascii")) 

531 ) 

532 return obj_class() 

533 

534 def _parse_legacy_object( 

535 self, 

536 map: bytes, 

537 max_size: int = DEFAULT_LOOSE_OBJECT_SIZE_LIMIT, 

538 ) -> None: 

539 """Parse a legacy object, setting the raw string.""" 

540 text = _decompress(map, max_size=max_size) 

541 header_end = text.find(b"\0") 

542 if header_end < 0: 

543 raise ObjectFormatException("Invalid object header, no \\0") 

544 self.set_raw_string(text[header_end + 1 :]) 

545 

546 def as_legacy_object_chunks(self, compression_level: int = -1) -> Iterator[bytes]: 

547 """Return chunks representing the object in the experimental format. 

548 

549 Returns: List of strings 

550 """ 

551 compobj = zlib.compressobj(compression_level) 

552 yield compobj.compress(self._header()) 

553 for chunk in self.as_raw_chunks(): 

554 yield compobj.compress(chunk) 

555 yield compobj.flush() 

556 

557 def as_legacy_object(self, compression_level: int = -1) -> bytes: 

558 """Return string representing the object in the experimental format.""" 

559 return b"".join( 

560 self.as_legacy_object_chunks(compression_level=compression_level) 

561 ) 

562 

563 def as_raw_chunks(self) -> list[bytes]: 

564 """Return chunks with serialization of the object. 

565 

566 Returns: List of strings, not necessarily one per line 

567 """ 

568 if self._needs_serialization: 

569 self._sha = None 

570 self._chunked_text = self._serialize() 

571 self._needs_serialization = False 

572 assert self._chunked_text is not None 

573 return self._chunked_text 

574 

575 def as_raw_string(self) -> bytes: 

576 """Return raw string with serialization of the object. 

577 

578 Returns: String object 

579 """ 

580 return b"".join(self.as_raw_chunks()) 

581 

582 def __bytes__(self) -> bytes: 

583 """Return raw string serialization of this object.""" 

584 return self.as_raw_string() 

585 

586 def __hash__(self) -> int: 

587 """Return unique hash for this object.""" 

588 return hash(self.id) 

589 

590 def as_pretty_string(self) -> str: 

591 """Return a string representing this object, fit for display.""" 

592 return self.as_raw_string().decode("utf-8", "replace") 

593 

594 def set_raw_string( 

595 self, 

596 text: bytes, 

597 sha: ObjectID | RawObjectID | None = None, 

598 *, 

599 verify_sha: ObjectID | RawObjectID | None = None, 

600 ) -> None: 

601 """Set the contents of this object from a serialized string.""" 

602 if not isinstance(text, bytes): 

603 raise TypeError(f"Expected bytes for text, got {text!r}") 

604 self.set_raw_chunks([text], sha, verify_sha=verify_sha) 

605 

606 def set_raw_chunks( 

607 self, 

608 chunks: list[bytes], 

609 sha: ObjectID | RawObjectID | None = None, 

610 *, 

611 object_format: ObjectFormat | None = None, 

612 verify_sha: ObjectID | RawObjectID | None = None, 

613 ) -> None: 

614 """Set the contents of this object from a list of chunks. 

615 

616 Args: 

617 chunks: The raw uncompressed contents. 

618 sha: Optional known, trusted sha for the object. Cached without 

619 being checked against the contents. 

620 object_format: Optional hash algorithm for the object. 

621 verify_sha: Optional sha that the contents are checked against; 

622 raises ChecksumMismatch if the object does not hash to it. On 

623 success it is cached like ``sha``. Mutually exclusive with ``sha``. 

624 """ 

625 if sha is not None and verify_sha is not None: 

626 raise ValueError("sha and verify_sha are mutually exclusive") 

627 self._chunked_text = chunks 

628 # Set hash algorithm if provided 

629 if object_format is not None: 

630 self.object_format = object_format 

631 # Set SHA before deserialization so Tree can use hash algorithm 

632 if sha is None: 

633 self._sha = None 

634 else: 

635 self._sha = FixedSha(sha) 

636 self._deserialize(chunks) 

637 self._needs_serialization = False 

638 if verify_sha is not None: 

639 got = self.get_id(self.object_format) 

640 if got != verify_sha: 

641 raise ChecksumMismatch(verify_sha, got) 

642 self._sha = FixedSha(verify_sha) 

643 

644 @staticmethod 

645 def _parse_object_header( 

646 magic: bytes, f: BufferedIOBase | IO[bytes] | "_GitFile" 

647 ) -> "ShaFile": 

648 """Parse a new style object, creating it but not reading the file.""" 

649 num_type = (ord(magic[0:1]) >> 4) & 7 

650 obj_class = object_class(num_type) 

651 if not obj_class: 

652 raise ObjectFormatException(f"Not a known type {num_type}") 

653 return obj_class() 

654 

655 def _parse_object( 

656 self, 

657 map: bytes, 

658 max_size: int = DEFAULT_LOOSE_OBJECT_SIZE_LIMIT, 

659 ) -> None: 

660 """Parse a new style object, setting self._text.""" 

661 # skip type and size; type must have already been determined, and 

662 # we trust zlib to fail if it's otherwise corrupted 

663 byte = ord(map[0:1]) 

664 used = 1 

665 while (byte & 0x80) != 0: 

666 byte = ord(map[used : used + 1]) 

667 used += 1 

668 raw = map[used:] 

669 self.set_raw_string(_decompress(raw, max_size=max_size)) 

670 

671 @classmethod 

672 def _is_legacy_object(cls, magic: bytes) -> bool: 

673 b0 = ord(magic[0:1]) 

674 b1 = ord(magic[1:2]) 

675 word = (b0 << 8) + b1 

676 return (b0 & 0x8F) == 0x08 and (word % 31) == 0 

677 

678 @classmethod 

679 def _parse_file( 

680 cls, 

681 f: BufferedIOBase | IO[bytes] | "_GitFile", 

682 *, 

683 object_format: ObjectFormat | None = None, 

684 max_size: int = DEFAULT_LOOSE_OBJECT_SIZE_LIMIT, 

685 ) -> "ShaFile": 

686 map = f.read() 

687 if not map: 

688 raise EmptyFileException("Corrupted empty file detected") 

689 

690 if cls._is_legacy_object(map): 

691 obj = cls._parse_legacy_object_header(map) 

692 if object_format is not None: 

693 obj.object_format = object_format 

694 obj._parse_legacy_object(map, max_size=max_size) 

695 else: 

696 obj = cls._parse_object_header(map, f) 

697 if object_format is not None: 

698 obj.object_format = object_format 

699 obj._parse_object(map, max_size=max_size) 

700 return obj 

701 

702 def _deserialize(self, chunks: list[bytes]) -> None: 

703 raise NotImplementedError(self._deserialize) 

704 

705 def _serialize(self) -> list[bytes]: 

706 raise NotImplementedError(self._serialize) 

707 

708 @classmethod 

709 def from_path( 

710 cls, 

711 path: str | bytes, 

712 sha: ObjectID | None = None, 

713 *, 

714 object_format: ObjectFormat | None = None, 

715 max_size: int = DEFAULT_LOOSE_OBJECT_SIZE_LIMIT, 

716 ) -> "ShaFile": 

717 """Open a SHA file from disk.""" 

718 with GitFile(path, "rb") as f: 

719 return cls.from_file(f, sha, object_format=object_format, max_size=max_size) 

720 

721 @classmethod 

722 def from_file( 

723 cls, 

724 f: BufferedIOBase | IO[bytes] | "_GitFile", 

725 sha: ObjectID | None = None, 

726 *, 

727 object_format: ObjectFormat | None = None, 

728 max_size: int = DEFAULT_LOOSE_OBJECT_SIZE_LIMIT, 

729 ) -> "ShaFile": 

730 """Get the contents of a SHA file on disk.""" 

731 try: 

732 # Validate SHA length matches hash algorithm if both provided 

733 if sha is not None and object_format is not None: 

734 expected_len = object_format.hex_length 

735 if len(sha) != expected_len: 

736 raise ValueError( 

737 f"SHA length {len(sha)} doesn't match hash algorithm " 

738 f"{object_format.name} (expected {expected_len})" 

739 ) 

740 

741 obj = cls._parse_file(f, object_format=object_format, max_size=max_size) 

742 if sha is not None: 

743 obj._sha = FixedSha(sha) 

744 else: 

745 obj._sha = None 

746 return obj 

747 except (IndexError, ValueError) as exc: 

748 raise ObjectFormatException("invalid object header") from exc 

749 

750 @staticmethod 

751 def from_raw_string( 

752 type_num: int, 

753 string: bytes, 

754 sha: ObjectID | RawObjectID | None = None, 

755 *, 

756 object_format: ObjectFormat | None = None, 

757 verify_sha: ObjectID | RawObjectID | None = None, 

758 ) -> "ShaFile": 

759 """Creates an object of the indicated type from the raw string given. 

760 

761 Args: 

762 type_num: The numeric type of the object. 

763 string: The raw uncompressed contents. 

764 sha: Optional known, trusted sha for the object. 

765 object_format: Optional hash algorithm for the object 

766 verify_sha: Optional sha to check the contents against; raises 

767 ChecksumMismatch on mismatch. Mutually exclusive with ``sha``. 

768 """ 

769 cls = object_class(type_num) 

770 if cls is None: 

771 raise AssertionError(f"unsupported class type num: {type_num}") 

772 obj = cls() 

773 if object_format is not None: 

774 obj.object_format = object_format 

775 obj.set_raw_string(string, sha, verify_sha=verify_sha) 

776 return obj 

777 

778 @staticmethod 

779 def from_raw_chunks( 

780 type_num: int, 

781 chunks: list[bytes], 

782 sha: ObjectID | RawObjectID | None = None, 

783 *, 

784 object_format: ObjectFormat | None = None, 

785 ) -> "ShaFile": 

786 """Creates an object of the indicated type from the raw chunks given. 

787 

788 Args: 

789 type_num: The numeric type of the object. 

790 chunks: An iterable of the raw uncompressed contents. 

791 sha: Optional known sha for the object 

792 object_format: Optional object format (hash algorithm) for the object. 

793 Required for trees in SHA-256 repositories so entry parsing uses 

794 the correct OID length. 

795 """ 

796 cls = object_class(type_num) 

797 if cls is None: 

798 raise AssertionError(f"unsupported class type num: {type_num}") 

799 obj = cls() 

800 obj.set_raw_chunks(chunks, sha, object_format=object_format) 

801 return obj 

802 

803 @classmethod 

804 def from_string(cls, string: bytes) -> Self: 

805 """Create a ShaFile from a string.""" 

806 obj = cls() 

807 obj.set_raw_string(string) 

808 return obj 

809 

810 def _check_has_member(self, member: str, error_msg: str) -> None: 

811 """Check that the object has a given member variable. 

812 

813 Args: 

814 member: the member variable to check for 

815 error_msg: the message for an error if the member is missing 

816 Raises: 

817 ObjectFormatException: with the given error_msg if member is 

818 missing or is None 

819 """ 

820 if getattr(self, member, None) is None: 

821 raise ObjectFormatException(error_msg) 

822 

823 def check(self) -> None: 

824 """Check this object for internal consistency. 

825 

826 Raises: 

827 ObjectFormatException: if the object is malformed in some way 

828 ChecksumMismatch: if the object was created with a SHA that does 

829 not match its contents 

830 """ 

831 # TODO: if we find that error-checking during object parsing is a 

832 # performance bottleneck, those checks should be moved to the class's 

833 # check() method during optimization so we can still check the object 

834 # when necessary. 

835 old_sha = self.id 

836 try: 

837 self._deserialize(self.as_raw_chunks()) 

838 self._sha = None 

839 new_sha = self.id 

840 except Exception as exc: 

841 raise ObjectFormatException(exc) from exc 

842 if old_sha != new_sha: 

843 raise ChecksumMismatch(new_sha, old_sha) 

844 

845 def _header(self) -> bytes: 

846 return object_header(self.type_num, self.raw_length()) 

847 

848 def raw_length(self) -> int: 

849 """Returns the length of the raw string of this object.""" 

850 return sum(map(len, self.as_raw_chunks())) 

851 

852 def sha(self, object_format: ObjectFormat | None = None) -> "FixedSha | HASH": 

853 """The SHA object that is the name of this object. 

854 

855 Args: 

856 object_format: Optional HashAlgorithm to use. Defaults to SHA1. 

857 """ 

858 # If using a different hash algorithm, always recalculate 

859 if object_format is not None: 

860 new_sha = object_format.new_hash() 

861 new_sha.update(self._header()) 

862 for chunk in self.as_raw_chunks(): 

863 new_sha.update(chunk) 

864 return new_sha 

865 

866 # Otherwise use cached SHA1 value 

867 if self._sha is None or self._needs_serialization: 

868 # this is a local because as_raw_chunks() overwrites self._sha 

869 new_sha = sha1() 

870 new_sha.update(self._header()) 

871 for chunk in self.as_raw_chunks(): 

872 new_sha.update(chunk) 

873 self._sha = new_sha 

874 return self._sha 

875 

876 def copy(self) -> "ShaFile": 

877 """Create a new copy of this SHA1 object from its raw string.""" 

878 obj_class = object_class(self.type_num) 

879 if obj_class is None: 

880 raise AssertionError(f"invalid type num {self.type_num}") 

881 return obj_class.from_raw_string(self.type_num, self.as_raw_string(), self.id) 

882 

883 @property 

884 def id(self) -> ObjectID: 

885 """The hex SHA1 of this object. 

886 

887 For SHA256 repositories, use get_id(object_format) instead. 

888 This property always returns SHA1 for backward compatibility. 

889 """ 

890 return ObjectID(self.sha().hexdigest().encode("ascii")) 

891 

892 def get_id(self, object_format: ObjectFormat | None = None) -> bytes: 

893 """Get the hex SHA of this object using the specified hash algorithm. 

894 

895 Args: 

896 object_format: Optional HashAlgorithm to use. Defaults to SHA1. 

897 

898 Example: 

899 >>> blob = Blob() 

900 >>> blob.data = b"Hello, World!" 

901 >>> blob.id # Always returns SHA1 for backward compatibility 

902 b'4ab299c8ad6ed14f31923dd94f8b5f5cb89dfb54' 

903 >>> blob.get_id() # Same as .id 

904 b'4ab299c8ad6ed14f31923dd94f8b5f5cb89dfb54' 

905 >>> from dulwich.object_format import SHA256 

906 >>> blob.get_id(SHA256) # Get SHA256 hash 

907 b'03ba204e2f2e707...' # 64-character SHA256 

908 """ 

909 return self.sha(object_format).hexdigest().encode("ascii") 

910 

911 def __repr__(self) -> str: 

912 """Return string representation of this object.""" 

913 return f"<{self.__class__.__name__} {self.id!r}>" 

914 

915 def __ne__(self, other: object) -> bool: 

916 """Check whether this object does not match the other.""" 

917 return not isinstance(other, ShaFile) or self.id != other.id 

918 

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

920 """Return True if the SHAs of the two objects match.""" 

921 return isinstance(other, ShaFile) and self.id == other.id 

922 

923 def __lt__(self, other: object) -> bool: 

924 """Return whether SHA of this object is less than the other.""" 

925 if not isinstance(other, ShaFile): 

926 raise TypeError 

927 return self.id < other.id 

928 

929 def __le__(self, other: object) -> bool: 

930 """Check whether SHA of this object is less than or equal to the other.""" 

931 if not isinstance(other, ShaFile): 

932 raise TypeError 

933 return self.id <= other.id 

934 

935 

936class Blob(ShaFile): 

937 """A Git Blob object.""" 

938 

939 __slots__ = () 

940 

941 type_name = b"blob" 

942 type_num = 3 

943 

944 def __init__(self) -> None: 

945 """Initialize a new Blob object.""" 

946 super().__init__() 

947 self._chunked_text = [] 

948 self._needs_serialization = False 

949 

950 @property 

951 def data(self) -> bytes: 

952 """The text contained within the blob object.""" 

953 return self.as_raw_string() 

954 

955 @data.setter 

956 def data(self, data: bytes) -> None: 

957 self.set_raw_string(data) 

958 

959 @property 

960 def chunked(self) -> list[bytes]: 

961 """The text in the blob object, as chunks (not necessarily lines).""" 

962 assert self._chunked_text is not None 

963 return self._chunked_text 

964 

965 @chunked.setter 

966 def chunked(self, chunks: list[bytes]) -> None: 

967 self._chunked_text = chunks 

968 

969 def _serialize(self) -> list[bytes]: 

970 assert self._chunked_text is not None 

971 return self._chunked_text 

972 

973 def _deserialize(self, chunks: list[bytes]) -> None: 

974 self._chunked_text = chunks 

975 

976 @classmethod 

977 def from_path( 

978 cls, 

979 path: str | bytes, 

980 sha: ObjectID | None = None, 

981 *, 

982 object_format: ObjectFormat | None = None, 

983 max_size: int = DEFAULT_LOOSE_OBJECT_SIZE_LIMIT, 

984 ) -> "Blob": 

985 """Read a blob from a file on disk. 

986 

987 Args: 

988 path: Path to the blob file 

989 sha: Optional known SHA for the object 

990 object_format: Optional object format to use 

991 max_size: Maximum inflated size in bytes 

992 

993 Returns: 

994 A Blob object 

995 

996 Raises: 

997 NotBlobError: If the file is not a blob 

998 """ 

999 blob = ShaFile.from_path( 

1000 path, sha, object_format=object_format, max_size=max_size 

1001 ) 

1002 if not isinstance(blob, cls): 

1003 raise NotBlobError(_path_to_bytes(path)) 

1004 return blob 

1005 

1006 def check(self) -> None: 

1007 """Check this object for internal consistency. 

1008 

1009 Raises: 

1010 ObjectFormatException: if the object is malformed in some way 

1011 """ 

1012 super().check() 

1013 

1014 def splitlines(self) -> list[bytes]: 

1015 """Return list of lines in this blob. 

1016 

1017 This preserves the original line endings. 

1018 """ 

1019 chunks = self.chunked 

1020 if not chunks: 

1021 return [] 

1022 if len(chunks) == 1: 

1023 result: list[bytes] = chunks[0].splitlines(True) 

1024 return result 

1025 remaining = None 

1026 ret = [] 

1027 for chunk in chunks: 

1028 lines = chunk.splitlines(True) 

1029 if len(lines) > 1: 

1030 ret.append((remaining or b"") + lines[0]) 

1031 ret.extend(lines[1:-1]) 

1032 remaining = lines[-1] 

1033 elif len(lines) == 1: 

1034 if remaining is None: 

1035 remaining = lines.pop() 

1036 else: 

1037 remaining += lines.pop() 

1038 if remaining is not None: 

1039 ret.append(remaining) 

1040 return ret 

1041 

1042 

1043def _parse_message( 

1044 chunks: Iterable[bytes], 

1045) -> Iterator[tuple[None, None] | tuple[bytes | None, bytes]]: 

1046 """Parse a message with a list of fields and a body. 

1047 

1048 Args: 

1049 chunks: the raw chunks of the tag or commit object. 

1050 Returns: iterator of tuples of (field, value), one per header line, in the 

1051 order read from the text, possibly including duplicates. Includes a 

1052 field named None for the freeform tag/commit text. 

1053 """ 

1054 f = BytesIO(b"".join(chunks)) 

1055 k = None 

1056 v: list[bytes] = [] 

1057 eof = False 

1058 

1059 def _strip_last_newline(value: bytes) -> bytes: 

1060 """Strip the last newline from value.""" 

1061 if value and value.endswith(b"\n"): 

1062 return value[:-1] 

1063 return value 

1064 

1065 # Parse the headers 

1066 # 

1067 # Headers can contain newlines. The next line is indented with a space. 

1068 # We store the latest key as 'k', and the accumulated value chunks as 'v'. 

1069 # The chunks are joined only once per header (rather than with ``+=`` per 

1070 # continuation line) so a value split across many indented lines stays 

1071 # linear instead of quadratic. 

1072 for line in f: 

1073 if line.startswith(b" "): 

1074 # Indented continuation of the previous line 

1075 v.append(line[1:]) 

1076 else: 

1077 if k is not None: 

1078 # We parsed a new header, return its value 

1079 yield (k, _strip_last_newline(b"".join(v))) 

1080 if line == b"\n": 

1081 # Empty line indicates end of headers 

1082 break 

1083 (k, rest) = line.split(b" ", 1) 

1084 v = [rest] 

1085 

1086 else: 

1087 # We reached end of file before the headers ended. We still need to 

1088 # return the previous header, then we need to return a None field for 

1089 # the text. 

1090 eof = True 

1091 if k is not None: 

1092 yield (k, _strip_last_newline(b"".join(v))) 

1093 yield (None, None) 

1094 

1095 if not eof: 

1096 # We didn't reach the end of file while parsing headers. We can return 

1097 # the rest of the file as a message. 

1098 yield (None, f.read()) 

1099 

1100 f.close() 

1101 

1102 

1103def _format_message( 

1104 headers: Sequence[tuple[bytes, bytes]], body: bytes | None 

1105) -> Iterator[bytes]: 

1106 for field, value in headers: 

1107 lines = value.split(b"\n") 

1108 yield git_line(field, lines[0]) 

1109 for line in lines[1:]: 

1110 yield b" " + line + b"\n" 

1111 yield b"\n" # There must be a new line after the headers 

1112 if body: 

1113 yield body 

1114 

1115 

1116class Tag(ShaFile): 

1117 """A Git Tag object.""" 

1118 

1119 type_name = b"tag" 

1120 type_num = 4 

1121 

1122 __slots__ = ( 

1123 "_message", 

1124 "_name", 

1125 "_object_class", 

1126 "_object_sha", 

1127 "_signature", 

1128 "_tag_time", 

1129 "_tag_timezone", 

1130 "_tag_timezone_neg_utc", 

1131 "_tagger", 

1132 ) 

1133 

1134 _message: bytes | None 

1135 _name: bytes | None 

1136 _object_class: "type[ShaFile] | None" 

1137 _object_sha: bytes | None 

1138 _signature: bytes | None 

1139 _tag_time: int | None 

1140 _tag_timezone: int | None 

1141 _tag_timezone_neg_utc: bool | None 

1142 _tagger: bytes | None 

1143 

1144 def __init__(self) -> None: 

1145 """Initialize a new Tag object.""" 

1146 super().__init__() 

1147 self._tagger = None 

1148 self._tag_time = None 

1149 self._tag_timezone = None 

1150 self._tag_timezone_neg_utc = False 

1151 self._signature: bytes | None = None 

1152 

1153 @classmethod 

1154 def from_path( 

1155 cls, 

1156 path: str | bytes, 

1157 sha: ObjectID | None = None, 

1158 *, 

1159 object_format: ObjectFormat | None = None, 

1160 max_size: int = DEFAULT_LOOSE_OBJECT_SIZE_LIMIT, 

1161 ) -> "Tag": 

1162 """Read a tag from a file on disk. 

1163 

1164 Args: 

1165 path: Path to the tag file 

1166 sha: Optional known SHA for the object 

1167 object_format: Optional object format to use 

1168 max_size: Maximum inflated size in bytes 

1169 

1170 Returns: 

1171 A Tag object 

1172 

1173 Raises: 

1174 NotTagError: If the file is not a tag 

1175 """ 

1176 tag = ShaFile.from_path( 

1177 path, sha, object_format=object_format, max_size=max_size 

1178 ) 

1179 if not isinstance(tag, cls): 

1180 raise NotTagError(_path_to_bytes(path)) 

1181 return tag 

1182 

1183 def check(self) -> None: 

1184 """Check this object for internal consistency. 

1185 

1186 Raises: 

1187 ObjectFormatException: if the object is malformed in some way 

1188 """ 

1189 super().check() 

1190 assert self._chunked_text is not None 

1191 self._check_has_member("_object_sha", "missing object sha") 

1192 self._check_has_member("_object_class", "missing object type") 

1193 self._check_has_member("_name", "missing tag name") 

1194 

1195 if not self._name: 

1196 raise ObjectFormatException("empty tag name") 

1197 

1198 if self._object_sha is None: 

1199 raise ObjectFormatException("missing object sha") 

1200 check_hexsha(self._object_sha, "invalid object sha") 

1201 

1202 if self._tagger is not None: 

1203 check_identity(self._tagger, "invalid tagger") 

1204 

1205 self._check_has_member("_tag_time", "missing tag time") 

1206 if self._tag_time is None: 

1207 raise ObjectFormatException("missing tag time") 

1208 check_time(self._tag_time) 

1209 

1210 last = None 

1211 for field, _ in _parse_message(self._chunked_text): 

1212 if field == _OBJECT_HEADER and last is not None: 

1213 raise ObjectFormatException("unexpected object") 

1214 elif field == _TYPE_HEADER and last != _OBJECT_HEADER: 

1215 raise ObjectFormatException("unexpected type") 

1216 elif field == _TAG_HEADER and last != _TYPE_HEADER: 

1217 raise ObjectFormatException("unexpected tag name") 

1218 elif field == _TAGGER_HEADER and last != _TAG_HEADER: 

1219 raise ObjectFormatException("unexpected tagger") 

1220 last = field 

1221 

1222 def _serialize(self) -> list[bytes]: 

1223 headers = [] 

1224 if self._object_sha is None: 

1225 raise ObjectFormatException("missing object sha") 

1226 headers.append((_OBJECT_HEADER, self._object_sha)) 

1227 if self._object_class is None: 

1228 raise ObjectFormatException("missing object class") 

1229 headers.append((_TYPE_HEADER, self._object_class.type_name)) 

1230 if self._name is None: 

1231 raise ObjectFormatException("missing tag name") 

1232 headers.append((_TAG_HEADER, self._name)) 

1233 if self._tagger: 

1234 if self._tag_time is None: 

1235 headers.append((_TAGGER_HEADER, self._tagger)) 

1236 else: 

1237 if self._tag_timezone is None or self._tag_timezone_neg_utc is None: 

1238 raise ObjectFormatException("missing timezone info") 

1239 headers.append( 

1240 ( 

1241 _TAGGER_HEADER, 

1242 format_time_entry( 

1243 self._tagger, 

1244 self._tag_time, 

1245 (self._tag_timezone, self._tag_timezone_neg_utc), 

1246 ), 

1247 ) 

1248 ) 

1249 

1250 if self.message is None and self._signature is None: 

1251 body = None 

1252 else: 

1253 body = (self.message or b"") + (self._signature or b"") 

1254 return list(_format_message(headers, body)) 

1255 

1256 def _deserialize(self, chunks: list[bytes]) -> None: 

1257 """Grab the metadata attached to the tag.""" 

1258 self._tagger = None 

1259 self._tag_time = None 

1260 self._tag_timezone = None 

1261 self._tag_timezone_neg_utc = False 

1262 for field, value in _parse_message(chunks): 

1263 if field == _OBJECT_HEADER: 

1264 self._object_sha = value 

1265 elif field == _TYPE_HEADER: 

1266 assert isinstance(value, bytes) 

1267 obj_class = object_class(value) 

1268 if not obj_class: 

1269 raise ObjectFormatException(f"Not a known type: {value!r}") 

1270 self._object_class = obj_class 

1271 elif field == _TAG_HEADER: 

1272 self._name = value 

1273 elif field == _TAGGER_HEADER: 

1274 if value is None: 

1275 raise ObjectFormatException("missing tagger value") 

1276 ( 

1277 self._tagger, 

1278 self._tag_time, 

1279 (self._tag_timezone, self._tag_timezone_neg_utc), 

1280 ) = parse_time_entry(value) 

1281 elif field is None: 

1282 if value is None: 

1283 self._message = None 

1284 self._signature = None 

1285 else: 

1286 # Try to find either PGP or SSH signature 

1287 sig_idx = None 

1288 try: 

1289 sig_idx = value.index(BEGIN_PGP_SIGNATURE) 

1290 except ValueError: 

1291 try: 

1292 sig_idx = value.index(BEGIN_SSH_SIGNATURE) 

1293 except ValueError: 

1294 pass 

1295 

1296 if sig_idx is not None: 

1297 self._message = value[:sig_idx] 

1298 self._signature = value[sig_idx:] 

1299 else: 

1300 self._message = value 

1301 self._signature = None 

1302 else: 

1303 raise ObjectFormatException( 

1304 f"Unknown field {field.decode('ascii', 'replace')}" 

1305 ) 

1306 

1307 @property 

1308 def object(self) -> tuple[type[ShaFile], ObjectID]: 

1309 """Get the object pointed to by this tag. 

1310 

1311 Returns: tuple of (object class, sha). 

1312 """ 

1313 if self._object_class is None or self._object_sha is None: 

1314 raise ValueError("Tag object is not properly initialized") 

1315 return (self._object_class, ObjectID(self._object_sha)) 

1316 

1317 @object.setter 

1318 def object(self, value: tuple[type[ShaFile], bytes]) -> None: 

1319 self._object_class, self._object_sha = value 

1320 self._needs_serialization = True 

1321 

1322 name = serializable_property("name", "The name of this tag") 

1323 tagger = serializable_property( 

1324 "tagger", "Returns the name of the person who created this tag" 

1325 ) 

1326 tag_time = serializable_property( 

1327 "tag_time", 

1328 "The creation timestamp of the tag. As the number of seconds since the epoch", 

1329 ) 

1330 tag_timezone = serializable_property( 

1331 "tag_timezone", "The timezone that tag_time is in." 

1332 ) 

1333 message = serializable_property("message", "the message attached to this tag") 

1334 

1335 signature = serializable_property("signature", "Optional detached GPG signature") 

1336 

1337 def raw_without_sig(self) -> bytes: 

1338 """Return raw string serialization without the GPG/SSH signature. 

1339 

1340 self.signature is a signature for the returned raw byte string serialization. 

1341 """ 

1342 ret = self.as_raw_string() 

1343 if self._signature: 

1344 ret = ret[: -len(self._signature)] 

1345 return ret 

1346 

1347 def extract_signature(self) -> tuple[bytes, bytes | None, bytes | None]: 

1348 """Extract the payload, signature, and signature type from this tag. 

1349 

1350 Returns: 

1351 tuple of (``payload``, ``signature``, ``signature_type``) where: 

1352 

1353 - ``payload``: The raw tag data without the signature 

1354 - ``signature``: The signature bytes if present, None otherwise 

1355 - ``signature_type``: SIGNATURE_PGP for PGP, SIGNATURE_SSH for SSH, None if no signature 

1356 

1357 Raises: 

1358 ObjectFormatException: If signature has unknown format 

1359 """ 

1360 if self._signature is None: 

1361 return self.as_raw_string(), None, None 

1362 

1363 payload = self.raw_without_sig() 

1364 

1365 # Determine signature type 

1366 if self._signature.startswith(BEGIN_PGP_SIGNATURE): 

1367 sig_type = SIGNATURE_PGP 

1368 elif self._signature.startswith(BEGIN_SSH_SIGNATURE): 

1369 sig_type = SIGNATURE_SSH 

1370 else: 

1371 raise ObjectFormatException("Unknown signature format") 

1372 

1373 return payload, self._signature, sig_type 

1374 

1375 

1376class TreeEntry(NamedTuple): 

1377 """Named tuple encapsulating a single tree entry.""" 

1378 

1379 path: bytes 

1380 mode: int 

1381 sha: ObjectID 

1382 

1383 def in_path(self, path: bytes) -> "TreeEntry": 

1384 """Return a copy of this entry with the given path prepended.""" 

1385 if not isinstance(self.path, bytes): 

1386 raise TypeError(f"Expected bytes for path, got {path!r}") 

1387 return TreeEntry(posixpath.join(path, self.path), self.mode, self.sha) 

1388 

1389 

1390def parse_tree( 

1391 text: bytes, sha_len: int | None = None, *, strict: bool = False 

1392) -> Iterator[tuple[bytes, int, bytes]]: 

1393 """Parse a tree text. 

1394 

1395 Args: 

1396 text: Serialized text to parse 

1397 sha_len: Length of the object IDs in bytes 

1398 strict: Whether to be strict about format 

1399 Returns: iterator of tuples of (name, mode, sha) 

1400 

1401 Raises: 

1402 ObjectFormatException: if the object was malformed in some way 

1403 """ 

1404 count = 0 

1405 length = len(text) 

1406 

1407 while count < length: 

1408 mode_end = text.index(b" ", count) 

1409 mode_text = text[count:mode_end] 

1410 if strict and mode_text.startswith(b"0"): 

1411 raise ObjectFormatException(f"Invalid mode {mode_text!r}") 

1412 try: 

1413 mode = int(mode_text, 8) 

1414 except ValueError as exc: 

1415 raise ObjectFormatException(f"Invalid mode {mode_text!r}") from exc 

1416 name_end = text.index(b"\0", mode_end) 

1417 name = text[mode_end + 1 : name_end] 

1418 

1419 if sha_len is None: 

1420 raise ObjectFormatException("sha_len must be specified") 

1421 count = name_end + 1 + sha_len 

1422 if count > length: 

1423 raise ObjectFormatException( 

1424 f"Tree entry extends beyond tree length: {count} > {length}" 

1425 ) 

1426 

1427 sha = text[name_end + 1 : count] 

1428 if len(sha) != sha_len: 

1429 raise ObjectFormatException( 

1430 f"Sha has invalid length: {len(sha)} != {sha_len}" 

1431 ) 

1432 hexsha = sha_to_hex(RawObjectID(sha)) 

1433 yield (name, mode, hexsha) 

1434 

1435 

1436def serialize_tree(items: Iterable[tuple[bytes, int, ObjectID]]) -> Iterator[bytes]: 

1437 """Serialize the items in a tree to a text. 

1438 

1439 Args: 

1440 items: Sorted iterable over (name, mode, sha) tuples 

1441 Returns: Serialized tree text as chunks 

1442 """ 

1443 for name, mode, hexsha in items: 

1444 yield ( 

1445 (f"{mode:04o}").encode("ascii") + b" " + name + b"\0" + hex_to_sha(hexsha) 

1446 ) 

1447 

1448 

1449def sorted_tree_items( 

1450 entries: dict[bytes, tuple[int, ObjectID]], name_order: bool 

1451) -> Iterator[TreeEntry]: 

1452 """Iterate over a tree entries dictionary. 

1453 

1454 Args: 

1455 name_order: If True, iterate entries in order of their name. If 

1456 False, iterate entries in tree order, that is, treat subtree entries as 

1457 having '/' appended. 

1458 entries: Dictionary mapping names to (mode, sha) tuples 

1459 Returns: Iterator over (name, mode, hexsha) 

1460 """ 

1461 if name_order: 

1462 key_func = key_entry_name_order 

1463 else: 

1464 key_func = key_entry 

1465 for name, entry in sorted(entries.items(), key=key_func): 

1466 mode, hexsha = entry 

1467 # Stricter type checks than normal to mirror checks in the Rust version. 

1468 mode = int(mode) 

1469 if not isinstance(hexsha, bytes): 

1470 raise TypeError(f"Expected bytes for SHA, got {hexsha!r}") 

1471 yield TreeEntry(name, mode, hexsha) 

1472 

1473 

1474def key_entry(entry: tuple[bytes, tuple[int, ObjectID]]) -> bytes: 

1475 """Sort key for tree entry. 

1476 

1477 Args: 

1478 entry: (name, value) tuple 

1479 """ 

1480 (name, (mode, _sha)) = entry 

1481 if stat.S_ISDIR(mode): 

1482 name += b"/" 

1483 return name 

1484 

1485 

1486def key_entry_name_order(entry: tuple[bytes, tuple[int, ObjectID]]) -> bytes: 

1487 """Sort key for tree entry in name order.""" 

1488 return entry[0] 

1489 

1490 

1491def pretty_format_tree_entry( 

1492 name: bytes, mode: int, hexsha: ObjectID, encoding: str = "utf-8" 

1493) -> str: 

1494 """Pretty format tree entry. 

1495 

1496 Args: 

1497 name: Name of the directory entry 

1498 mode: Mode of entry 

1499 hexsha: Hexsha of the referenced object 

1500 encoding: Character encoding for the name 

1501 Returns: string describing the tree entry 

1502 """ 

1503 if mode & stat.S_IFDIR: 

1504 kind = "tree" 

1505 else: 

1506 kind = "blob" 

1507 return "{:04o} {} {}\t{}\n".format( 

1508 mode, 

1509 kind, 

1510 hexsha.decode("ascii"), 

1511 name.decode(encoding, "replace"), 

1512 ) 

1513 

1514 

1515class SubmoduleEncountered(Exception): 

1516 """A submodule was encountered while resolving a path.""" 

1517 

1518 def __init__(self, path: bytes, sha: ObjectID) -> None: 

1519 """Initialize SubmoduleEncountered exception. 

1520 

1521 Args: 

1522 path: Path where the submodule was encountered 

1523 sha: SHA of the submodule 

1524 """ 

1525 self.path = path 

1526 self.sha = sha 

1527 

1528 

1529class Tree(ShaFile): 

1530 """A Git tree object.""" 

1531 

1532 type_name = b"tree" 

1533 type_num = 2 

1534 

1535 __slots__ = "_entries" 

1536 

1537 def __init__(self) -> None: 

1538 """Initialize an empty Tree.""" 

1539 super().__init__() 

1540 self._entries: dict[bytes, tuple[int, ObjectID]] = {} 

1541 

1542 @classmethod 

1543 def from_path( 

1544 cls, 

1545 path: str | bytes, 

1546 sha: ObjectID | None = None, 

1547 *, 

1548 object_format: ObjectFormat | None = None, 

1549 max_size: int = DEFAULT_LOOSE_OBJECT_SIZE_LIMIT, 

1550 ) -> "Tree": 

1551 """Read a tree from a file on disk. 

1552 

1553 Args: 

1554 path: Path to the tree file 

1555 sha: Optional known SHA for the object 

1556 object_format: Optional object format to use 

1557 max_size: Maximum inflated size in bytes 

1558 

1559 Returns: 

1560 A Tree object 

1561 

1562 Raises: 

1563 NotTreeError: If the file is not a tree 

1564 """ 

1565 tree = ShaFile.from_path( 

1566 path, sha, object_format=object_format, max_size=max_size 

1567 ) 

1568 if not isinstance(tree, cls): 

1569 raise NotTreeError(_path_to_bytes(path)) 

1570 return tree 

1571 

1572 def __contains__(self, name: bytes) -> bool: 

1573 """Check if name exists in tree.""" 

1574 return name in self._entries 

1575 

1576 def __getitem__(self, name: bytes) -> tuple[int, ObjectID]: 

1577 """Get tree entry by name.""" 

1578 return self._entries[name] 

1579 

1580 def __setitem__(self, name: bytes, value: tuple[int, ObjectID]) -> None: 

1581 """Set a tree entry by name. 

1582 

1583 Args: 

1584 name: The name of the entry, as a string. 

1585 value: A tuple of (mode, hexsha), where mode is the mode of the 

1586 entry as an integral type and hexsha is the hex SHA of the entry as 

1587 a string. 

1588 """ 

1589 mode, hexsha = value 

1590 self._entries[name] = (mode, hexsha) 

1591 self._needs_serialization = True 

1592 

1593 def __delitem__(self, name: bytes) -> None: 

1594 """Delete tree entry by name.""" 

1595 del self._entries[name] 

1596 self._needs_serialization = True 

1597 

1598 def __len__(self) -> int: 

1599 """Return number of entries in tree.""" 

1600 return len(self._entries) 

1601 

1602 def __iter__(self) -> Iterator[bytes]: 

1603 """Iterate over tree entry names.""" 

1604 return iter(self._entries) 

1605 

1606 def add(self, name: bytes, mode: int, hexsha: ObjectID) -> None: 

1607 """Add an entry to the tree. 

1608 

1609 Args: 

1610 mode: The mode of the entry as an integral type. Not all 

1611 possible modes are supported by git; see check() for details. 

1612 name: The name of the entry, as a string. 

1613 hexsha: The hex SHA of the entry as a string. 

1614 """ 

1615 self._entries[name] = mode, hexsha 

1616 self._needs_serialization = True 

1617 

1618 def iteritems(self, name_order: bool = False) -> Iterator[TreeEntry]: 

1619 """Iterate over entries. 

1620 

1621 Args: 

1622 name_order: If True, iterate in name order instead of tree 

1623 order. 

1624 Returns: Iterator over (name, mode, sha) tuples 

1625 """ 

1626 return sorted_tree_items(self._entries, name_order) 

1627 

1628 def items(self) -> list[TreeEntry]: 

1629 """Return the sorted entries in this tree. 

1630 

1631 Returns: List with (name, mode, sha) tuples 

1632 """ 

1633 return list(self.iteritems()) 

1634 

1635 def _deserialize(self, chunks: list[bytes]) -> None: 

1636 """Grab the entries in the tree.""" 

1637 try: 

1638 parsed_entries = parse_tree( 

1639 b"".join(chunks), 

1640 sha_len=self.object_format.oid_length, 

1641 ) 

1642 except ValueError as exc: 

1643 raise ObjectFormatException(exc) from exc 

1644 # TODO: list comprehension is for efficiency in the common (small) 

1645 # case; if memory efficiency in the large case is a concern, use a 

1646 # genexp. 

1647 self._entries = {n: (m, ObjectID(s)) for n, m, s in parsed_entries} 

1648 

1649 def check(self) -> None: 

1650 """Check this object for internal consistency. 

1651 

1652 Raises: 

1653 ObjectFormatException: if the object is malformed in some way 

1654 """ 

1655 super().check() 

1656 assert self._chunked_text is not None 

1657 last = None 

1658 allowed_modes = ( 

1659 stat.S_IFREG | 0o755, 

1660 stat.S_IFREG | 0o644, 

1661 stat.S_IFLNK, 

1662 stat.S_IFDIR, 

1663 S_IFGITLINK, 

1664 # TODO: optionally exclude as in git fsck --strict 

1665 stat.S_IFREG | 0o664, 

1666 ) 

1667 for name, mode, sha in parse_tree( 

1668 b"".join(self._chunked_text), 

1669 strict=True, 

1670 sha_len=self.object_format.oid_length, 

1671 ): 

1672 check_hexsha(sha, f"invalid sha {sha!r}") 

1673 if b"/" in name or name in (b"", b".", b"..", b".git"): 

1674 raise ObjectFormatException( 

1675 "invalid name {}".format(name.decode("utf-8", "replace")) 

1676 ) 

1677 

1678 if mode not in allowed_modes: 

1679 raise ObjectFormatException(f"invalid mode {mode:06o}") 

1680 

1681 entry = (name, (mode, ObjectID(sha))) 

1682 if last: 

1683 if key_entry(last) > key_entry(entry): 

1684 raise ObjectFormatException("entries not sorted") 

1685 if name == last[0]: 

1686 raise ObjectFormatException(f"duplicate entry {name!r}") 

1687 last = entry 

1688 

1689 def _serialize(self) -> list[bytes]: 

1690 return list(serialize_tree(self.iteritems())) 

1691 

1692 def as_pretty_string(self) -> str: 

1693 """Return a human-readable string representation of this tree. 

1694 

1695 Returns: 

1696 Pretty-printed tree entries 

1697 """ 

1698 text: list[str] = [] 

1699 for entry in self.iteritems(): 

1700 if ( 

1701 entry.path is not None 

1702 and entry.mode is not None 

1703 and entry.sha is not None 

1704 ): 

1705 text.append(pretty_format_tree_entry(entry.path, entry.mode, entry.sha)) 

1706 return "".join(text) 

1707 

1708 def lookup_path( 

1709 self, lookup_obj: Callable[[ObjectID], ShaFile], path: bytes 

1710 ) -> tuple[int, ObjectID]: 

1711 """Look up an object in a Git tree. 

1712 

1713 Args: 

1714 lookup_obj: Callback for retrieving object by SHA1 

1715 path: Path to lookup 

1716 Returns: A tuple of (mode, SHA) of the resulting path. 

1717 """ 

1718 # Handle empty path - return the tree itself 

1719 if not path: 

1720 return stat.S_IFDIR, self.id 

1721 

1722 parts = path.split(b"/") 

1723 sha = self.id 

1724 mode: int | None = None 

1725 for i, p in enumerate(parts): 

1726 if not p: 

1727 continue 

1728 if mode is not None and S_ISGITLINK(mode): 

1729 raise SubmoduleEncountered(b"/".join(parts[:i]), sha) 

1730 obj = lookup_obj(sha) 

1731 if not isinstance(obj, Tree): 

1732 raise NotTreeError(sha) 

1733 mode, sha = obj[p] 

1734 if mode is None: 

1735 raise ValueError("No valid path found") 

1736 return mode, sha 

1737 

1738 

1739def parse_timezone(text: bytes) -> tuple[int, bool]: 

1740 """Parse a timezone text fragment (e.g. '+0100'). 

1741 

1742 Args: 

1743 text: Text to parse. 

1744 Returns: Tuple with timezone as seconds difference to UTC 

1745 and a boolean indicating whether this was a UTC timezone 

1746 prefixed with a negative sign (-0000). 

1747 """ 

1748 # cgit parses the first character as the sign, and the rest 

1749 # as an integer (using strtol), which could also be negative. 

1750 # We do the same for compatibility. See #697828. 

1751 if text[0] not in b"+-": 

1752 raise ValueError("Timezone must start with + or - ({text})".format(**vars())) 

1753 sign = text[:1] 

1754 offset = int(text[1:]) 

1755 if sign == b"-": 

1756 offset = -offset 

1757 unnecessary_negative_timezone = offset >= 0 and sign == b"-" 

1758 signum = ((offset < 0) and -1) or 1 

1759 offset = abs(offset) 

1760 hours = int(offset / 100) 

1761 minutes = offset % 100 

1762 return ( 

1763 signum * (hours * 3600 + minutes * 60), 

1764 unnecessary_negative_timezone, 

1765 ) 

1766 

1767 

1768def parse_timezone_broken(text: bytes) -> tuple[int, bool]: 

1769 """Parse a timezone text fragment, accepting broken formats. 

1770 

1771 This function handles various broken timezone formats found in the wild: 

1772 - Missing sign prefix (e.g., '0000' instead of '+0000') 

1773 - Double negative (e.g., '--700') 

1774 

1775 Args: 

1776 text: Text to parse. 

1777 Returns: Tuple with timezone as seconds difference to UTC 

1778 and a boolean indicating whether this was a UTC timezone 

1779 prefixed with a negative sign (-0000). 

1780 """ 

1781 if text[0] not in b"+-": 

1782 # Some (broken) commits do not have a sign 

1783 text = b"+" + text 

1784 

1785 # cgit parses the first character as the sign, and the rest 

1786 # as an integer (using strtol), which could also be negative. 

1787 # We do the same for compatibility. See #697828. 

1788 sign = text[:1] 

1789 offset = int(text[1:]) 

1790 if sign == b"-": 

1791 offset = -offset 

1792 unnecessary_negative_timezone = offset >= 0 and sign == b"-" 

1793 signum = ((offset < 0) and -1) or 1 

1794 offset = abs(offset) 

1795 hours = int(offset / 100) 

1796 minutes = offset % 100 

1797 return ( 

1798 signum * (hours * 3600 + minutes * 60), 

1799 unnecessary_negative_timezone, 

1800 ) 

1801 

1802 

1803def format_timezone(offset: int, unnecessary_negative_timezone: bool = False) -> bytes: 

1804 """Format a timezone for Git serialization. 

1805 

1806 Args: 

1807 offset: Timezone offset as seconds difference to UTC 

1808 unnecessary_negative_timezone: Whether to use a minus sign for 

1809 UTC or positive timezones (-0000 and --700 rather than +0000 / +0700). 

1810 """ 

1811 if offset % 60 != 0: 

1812 raise ValueError("Unable to handle non-minute offset.") 

1813 if offset < 0 or unnecessary_negative_timezone: 

1814 sign = "-" 

1815 offset = -offset 

1816 else: 

1817 sign = "+" 

1818 return ("%c%02d%02d" % (sign, offset / 3600, (offset / 60) % 60)).encode("ascii") # noqa: UP031 

1819 

1820 

1821def parse_time_entry( 

1822 value: bytes, 

1823) -> tuple[bytes, int | None, tuple[int | None, bool]]: 

1824 """Parse event. 

1825 

1826 Args: 

1827 value: Bytes representing a git commit/tag line 

1828 Raises: 

1829 ObjectFormatException in case of parsing error (malformed 

1830 field date) 

1831 Returns: Tuple of (author, time, (timezone, timezone_neg_utc)) 

1832 """ 

1833 try: 

1834 sep = value.rindex(b"> ") 

1835 except ValueError: 

1836 return (value, None, (None, False)) 

1837 try: 

1838 person = value[0 : sep + 1] 

1839 rest = value[sep + 2 :] 

1840 timetext, timezonetext = rest.rsplit(b" ", 1) 

1841 time = int(timetext) 

1842 timezone, timezone_neg_utc = parse_timezone(timezonetext) 

1843 except ValueError as exc: 

1844 raise ObjectFormatException(exc) from exc 

1845 return person, time, (timezone, timezone_neg_utc) 

1846 

1847 

1848def parse_time_entry_broken( 

1849 value: bytes, 

1850) -> tuple[bytes, int | None, tuple[int | None, bool]]: 

1851 """Parse event, accepting broken formats. 

1852 

1853 This function handles various broken author/committer/tagger line formats: 

1854 - Missing angle brackets around email 

1855 - Unsigned timezones 

1856 - Double-negative timezones 

1857 

1858 Args: 

1859 value: Bytes representing a git commit/tag line 

1860 Raises: 

1861 ObjectFormatException in case of parsing error 

1862 Returns: Tuple of (author, time, (timezone, timezone_neg_utc)) 

1863 """ 

1864 m = _TIME_ENTRY_RE.match(value) 

1865 if not m: 

1866 raise ObjectFormatException(f"Unable to parse time entry: {value!r}") 

1867 

1868 person = m.group("person") 

1869 timetext = m.group("time") 

1870 timezonetext = m.group("timezone") 

1871 time = int(timetext) 

1872 timezone, timezone_neg_utc = parse_timezone_broken(timezonetext) 

1873 

1874 return person, time, (timezone, timezone_neg_utc) 

1875 

1876 

1877def format_time_entry( 

1878 person: bytes, time: int, timezone_info: tuple[int, bool] 

1879) -> bytes: 

1880 """Format an event.""" 

1881 (timezone, timezone_neg_utc) = timezone_info 

1882 return b" ".join( 

1883 [person, str(time).encode("ascii"), format_timezone(timezone, timezone_neg_utc)] 

1884 ) 

1885 

1886 

1887def _parse_commit( 

1888 chunks: Iterable[bytes], 

1889) -> tuple[ 

1890 bytes | None, 

1891 list[bytes], 

1892 tuple[bytes | None, int | None, tuple[int | None, bool | None]], 

1893 tuple[bytes | None, int | None, tuple[int | None, bool | None]], 

1894 bytes | None, 

1895 list[Tag], 

1896 bytes | None, 

1897 bytes | None, 

1898 list[tuple[bytes, bytes]], 

1899]: 

1900 """Parse a commit object from chunks. 

1901 

1902 Args: 

1903 chunks: Chunks to parse 

1904 Returns: Tuple of (tree, parents, author_info, commit_info, 

1905 encoding, mergetag, gpgsig, message, extra) 

1906 """ 

1907 parents = [] 

1908 extra: list[tuple[bytes, bytes]] = [] 

1909 tree = None 

1910 author_info: tuple[bytes | None, int | None, tuple[int | None, bool | None]] = ( 

1911 None, 

1912 None, 

1913 (None, None), 

1914 ) 

1915 commit_info: tuple[bytes | None, int | None, tuple[int | None, bool | None]] = ( 

1916 None, 

1917 None, 

1918 (None, None), 

1919 ) 

1920 encoding = None 

1921 mergetag = [] 

1922 message = None 

1923 gpgsig = None 

1924 

1925 for field, value in _parse_message(chunks): 

1926 # TODO(jelmer): Enforce ordering 

1927 if field == _TREE_HEADER: 

1928 tree = value 

1929 elif field == _PARENT_HEADER: 

1930 if value is None: 

1931 raise ObjectFormatException("missing parent value") 

1932 parents.append(value) 

1933 elif field == _AUTHOR_HEADER: 

1934 if value is None: 

1935 raise ObjectFormatException("missing author value") 

1936 author_info = parse_time_entry(value) 

1937 elif field == _COMMITTER_HEADER: 

1938 if value is None: 

1939 raise ObjectFormatException("missing committer value") 

1940 commit_info = parse_time_entry(value) 

1941 elif field == _ENCODING_HEADER: 

1942 encoding = value 

1943 elif field == _MERGETAG_HEADER: 

1944 if value is None: 

1945 raise ObjectFormatException("missing mergetag value") 

1946 tag = Tag.from_string(value + b"\n") 

1947 assert isinstance(tag, Tag) 

1948 mergetag.append(tag) 

1949 elif field == _GPGSIG_HEADER: 

1950 gpgsig = value 

1951 elif field is None: 

1952 message = value 

1953 else: 

1954 if value is None: 

1955 raise ObjectFormatException(f"missing value for field {field!r}") 

1956 extra.append((field, value)) 

1957 return ( 

1958 tree, 

1959 parents, 

1960 author_info, 

1961 commit_info, 

1962 encoding, 

1963 mergetag, 

1964 gpgsig, 

1965 message, 

1966 extra, 

1967 ) 

1968 

1969 

1970def _parse_commit_broken( 

1971 chunks: Iterable[bytes], 

1972) -> tuple[ 

1973 bytes | None, 

1974 list[bytes], 

1975 tuple[bytes | None, int | None, tuple[int | None, bool | None]], 

1976 tuple[bytes | None, int | None, tuple[int | None, bool | None]], 

1977 bytes | None, 

1978 list[Tag], 

1979 bytes | None, 

1980 bytes | None, 

1981 list[tuple[bytes, bytes]], 

1982]: 

1983 """Parse a commit object from chunks, accepting broken formats. 

1984 

1985 This function handles various broken author/committer line formats: 

1986 - Missing angle brackets around email 

1987 - Unsigned timezones 

1988 - Double-negative timezones 

1989 

1990 Args: 

1991 chunks: Chunks to parse 

1992 Returns: Tuple of (tree, parents, author_info, commit_info, 

1993 encoding, mergetag, gpgsig, message, extra) 

1994 """ 

1995 parents = [] 

1996 extra: list[tuple[bytes, bytes]] = [] 

1997 tree = None 

1998 author_info: tuple[bytes | None, int | None, tuple[int | None, bool | None]] = ( 

1999 None, 

2000 None, 

2001 (None, None), 

2002 ) 

2003 commit_info: tuple[bytes | None, int | None, tuple[int | None, bool | None]] = ( 

2004 None, 

2005 None, 

2006 (None, None), 

2007 ) 

2008 encoding = None 

2009 mergetag = [] 

2010 message = None 

2011 gpgsig = None 

2012 

2013 for field, value in _parse_message(chunks): 

2014 # TODO(jelmer): Enforce ordering 

2015 if field == _TREE_HEADER: 

2016 tree = value 

2017 elif field == _PARENT_HEADER: 

2018 if value is None: 

2019 raise ObjectFormatException("missing parent value") 

2020 parents.append(value) 

2021 elif field == _AUTHOR_HEADER: 

2022 if value is None: 

2023 raise ObjectFormatException("missing author value") 

2024 author_info = parse_time_entry_broken(value) 

2025 elif field == _COMMITTER_HEADER: 

2026 if value is None: 

2027 raise ObjectFormatException("missing committer value") 

2028 commit_info = parse_time_entry_broken(value) 

2029 elif field == _ENCODING_HEADER: 

2030 encoding = value 

2031 elif field == _MERGETAG_HEADER: 

2032 if value is None: 

2033 raise ObjectFormatException("missing mergetag value") 

2034 tag = Tag.from_string(value + b"\n") 

2035 assert isinstance(tag, Tag) 

2036 mergetag.append(tag) 

2037 elif field == _GPGSIG_HEADER: 

2038 gpgsig = value 

2039 elif field is None: 

2040 message = value 

2041 else: 

2042 if value is None: 

2043 raise ObjectFormatException(f"missing value for field {field!r}") 

2044 extra.append((field, value)) 

2045 return ( 

2046 tree, 

2047 parents, 

2048 author_info, 

2049 commit_info, 

2050 encoding, 

2051 mergetag, 

2052 gpgsig, 

2053 message, 

2054 extra, 

2055 ) 

2056 

2057 

2058class Commit(ShaFile): 

2059 """A git commit object.""" 

2060 

2061 type_name = b"commit" 

2062 type_num = 1 

2063 

2064 __slots__ = ( 

2065 "_author", 

2066 "_author_time", 

2067 "_author_timezone", 

2068 "_author_timezone_neg_utc", 

2069 "_commit_time", 

2070 "_commit_timezone", 

2071 "_commit_timezone_neg_utc", 

2072 "_committer", 

2073 "_encoding", 

2074 "_extra", 

2075 "_gpgsig", 

2076 "_mergetag", 

2077 "_message", 

2078 "_parents", 

2079 "_tree", 

2080 ) 

2081 

2082 def __init__(self) -> None: 

2083 """Initialize an empty Commit.""" 

2084 super().__init__() 

2085 self._parents: list[ObjectID] = [] 

2086 self._encoding: bytes | None = None 

2087 self._mergetag: list[Tag] = [] 

2088 self._gpgsig: bytes | None = None 

2089 self._extra: list[tuple[bytes, bytes]] = [] 

2090 self._author_timezone_neg_utc: bool | None = False 

2091 self._commit_timezone_neg_utc: bool | None = False 

2092 

2093 @classmethod 

2094 def from_path( 

2095 cls, 

2096 path: str | bytes, 

2097 sha: ObjectID | None = None, 

2098 *, 

2099 object_format: ObjectFormat | None = None, 

2100 max_size: int = DEFAULT_LOOSE_OBJECT_SIZE_LIMIT, 

2101 ) -> "Commit": 

2102 """Read a commit from a file on disk. 

2103 

2104 Args: 

2105 path: Path to the commit file 

2106 sha: Optional known SHA for the object 

2107 object_format: Optional object format to use 

2108 max_size: Maximum inflated size in bytes 

2109 

2110 Returns: 

2111 A Commit object 

2112 

2113 Raises: 

2114 NotCommitError: If the file is not a commit 

2115 """ 

2116 commit = ShaFile.from_path( 

2117 path, sha, object_format=object_format, max_size=max_size 

2118 ) 

2119 if not isinstance(commit, cls): 

2120 raise NotCommitError(_path_to_bytes(path)) 

2121 return commit 

2122 

2123 def _deserialize(self, chunks: list[bytes]) -> None: 

2124 ( 

2125 tree, 

2126 parents, 

2127 author_info, 

2128 commit_info, 

2129 encoding, 

2130 mergetag, 

2131 gpgsig, 

2132 message, 

2133 extra, 

2134 ) = _parse_commit(chunks) 

2135 

2136 self._tree = tree 

2137 self._parents = [ObjectID(p) for p in parents] 

2138 self._encoding = encoding 

2139 self._mergetag = mergetag 

2140 self._gpgsig = gpgsig 

2141 self._message = message 

2142 self._extra = extra 

2143 

2144 ( 

2145 self._author, 

2146 self._author_time, 

2147 (self._author_timezone, self._author_timezone_neg_utc), 

2148 ) = author_info 

2149 ( 

2150 self._committer, 

2151 self._commit_time, 

2152 (self._commit_timezone, self._commit_timezone_neg_utc), 

2153 ) = commit_info 

2154 

2155 def check(self) -> None: 

2156 """Check this object for internal consistency. 

2157 

2158 Raises: 

2159 ObjectFormatException: if the object is malformed in some way 

2160 """ 

2161 super().check() 

2162 assert self._chunked_text is not None 

2163 self._check_has_member("_tree", "missing tree") 

2164 self._check_has_member("_author", "missing author") 

2165 self._check_has_member("_committer", "missing committer") 

2166 self._check_has_member("_author_time", "missing author time") 

2167 self._check_has_member("_commit_time", "missing commit time") 

2168 

2169 for parent in self._parents: 

2170 check_hexsha(parent, "invalid parent sha") 

2171 assert self._tree is not None # checked by _check_has_member above 

2172 check_hexsha(self._tree, "invalid tree sha") 

2173 

2174 assert self._author is not None # checked by _check_has_member above 

2175 assert self._committer is not None # checked by _check_has_member above 

2176 check_identity(self._author, "invalid author") 

2177 check_identity(self._committer, "invalid committer") 

2178 

2179 assert self._author_time is not None # checked by _check_has_member above 

2180 assert self._commit_time is not None # checked by _check_has_member above 

2181 check_time(self._author_time) 

2182 check_time(self._commit_time) 

2183 

2184 last = None 

2185 for field, _ in _parse_message(self._chunked_text): 

2186 if field == _TREE_HEADER and last is not None: 

2187 raise ObjectFormatException("unexpected tree") 

2188 elif field == _PARENT_HEADER and last not in ( 

2189 _PARENT_HEADER, 

2190 _TREE_HEADER, 

2191 ): 

2192 raise ObjectFormatException("unexpected parent") 

2193 elif field == _AUTHOR_HEADER and last not in ( 

2194 _TREE_HEADER, 

2195 _PARENT_HEADER, 

2196 ): 

2197 raise ObjectFormatException("unexpected author") 

2198 elif field == _COMMITTER_HEADER and last != _AUTHOR_HEADER: 

2199 raise ObjectFormatException("unexpected committer") 

2200 elif field == _ENCODING_HEADER and last != _COMMITTER_HEADER: 

2201 raise ObjectFormatException("unexpected encoding") 

2202 last = field 

2203 

2204 # TODO: optionally check for duplicate parents 

2205 

2206 def raw_without_sig(self) -> bytes: 

2207 """Return raw string serialization without the GPG/SSH signature. 

2208 

2209 self.gpgsig is a signature for the returned raw byte string serialization. 

2210 """ 

2211 tmp = self.copy() 

2212 assert isinstance(tmp, Commit) 

2213 tmp._gpgsig = None 

2214 tmp.gpgsig = None 

2215 return tmp.as_raw_string() 

2216 

2217 def extract_signature(self) -> tuple[bytes, bytes | None, bytes | None]: 

2218 """Extract the payload, signature, and signature type from this commit. 

2219 

2220 Returns: 

2221 tuple of (``payload``, ``signature``, ``signature_type``) where: 

2222 

2223 - ``payload``: The raw commit data without the signature 

2224 - ``signature``: The signature bytes if present, None otherwise 

2225 - ``signature_type``: SIGNATURE_PGP for PGP, SIGNATURE_SSH for SSH, None if no signature 

2226 

2227 Raises: 

2228 ObjectFormatException: If signature has unknown format 

2229 """ 

2230 if self._gpgsig is None: 

2231 return self.as_raw_string(), None, None 

2232 

2233 payload = self.raw_without_sig() 

2234 

2235 # Determine signature type 

2236 if self._gpgsig.startswith(BEGIN_PGP_SIGNATURE): 

2237 sig_type = SIGNATURE_PGP 

2238 elif self._gpgsig.startswith(BEGIN_SSH_SIGNATURE): 

2239 sig_type = SIGNATURE_SSH 

2240 else: 

2241 raise ObjectFormatException("Unknown signature format") 

2242 

2243 return payload, self._gpgsig, sig_type 

2244 

2245 def _serialize(self) -> list[bytes]: 

2246 headers = [] 

2247 assert self._tree is not None 

2248 tree_bytes = self._tree.id if isinstance(self._tree, Tree) else self._tree 

2249 headers.append((_TREE_HEADER, tree_bytes)) 

2250 for p in self._parents: 

2251 headers.append((_PARENT_HEADER, p)) 

2252 assert self._author is not None 

2253 assert self._author_time is not None 

2254 assert self._author_timezone is not None 

2255 assert self._author_timezone_neg_utc is not None 

2256 headers.append( 

2257 ( 

2258 _AUTHOR_HEADER, 

2259 format_time_entry( 

2260 self._author, 

2261 self._author_time, 

2262 (self._author_timezone, self._author_timezone_neg_utc), 

2263 ), 

2264 ) 

2265 ) 

2266 assert self._committer is not None 

2267 assert self._commit_time is not None 

2268 assert self._commit_timezone is not None 

2269 assert self._commit_timezone_neg_utc is not None 

2270 headers.append( 

2271 ( 

2272 _COMMITTER_HEADER, 

2273 format_time_entry( 

2274 self._committer, 

2275 self._commit_time, 

2276 (self._commit_timezone, self._commit_timezone_neg_utc), 

2277 ), 

2278 ) 

2279 ) 

2280 if self.encoding: 

2281 headers.append((_ENCODING_HEADER, self.encoding)) 

2282 for mergetag in self.mergetag: 

2283 headers.append((_MERGETAG_HEADER, mergetag.as_raw_string()[:-1])) 

2284 headers.extend( 

2285 (field, value) for field, value in self._extra if value is not None 

2286 ) 

2287 if self.gpgsig: 

2288 headers.append((_GPGSIG_HEADER, self.gpgsig)) 

2289 return list(_format_message(headers, self._message)) 

2290 

2291 tree = serializable_property("tree", "Tree that is the state of this commit") 

2292 

2293 @property 

2294 def parents(self) -> list[ObjectID]: 

2295 """Parents of this commit, by their SHA1.""" 

2296 return self._parents 

2297 

2298 @parents.setter 

2299 def parents(self, value: list[ObjectID]) -> None: 

2300 """Set a list of parents of this commit.""" 

2301 self._needs_serialization = True 

2302 self._parents = value 

2303 

2304 author = serializable_property("author", "The name of the author of the commit") 

2305 

2306 committer = serializable_property( 

2307 "committer", "The name of the committer of the commit" 

2308 ) 

2309 

2310 message = serializable_property("message", "The commit message") 

2311 

2312 commit_time = serializable_property( 

2313 "commit_time", 

2314 "The timestamp of the commit. As the number of seconds since the epoch.", 

2315 ) 

2316 

2317 commit_timezone = serializable_property( 

2318 "commit_timezone", "The zone the commit time is in" 

2319 ) 

2320 

2321 author_time = serializable_property( 

2322 "author_time", 

2323 "The timestamp the commit was written. As the number of " 

2324 "seconds since the epoch.", 

2325 ) 

2326 

2327 author_timezone = serializable_property( 

2328 "author_timezone", "Returns the zone the author time is in." 

2329 ) 

2330 

2331 encoding = serializable_property("encoding", "Encoding of the commit message.") 

2332 

2333 mergetag = serializable_property("mergetag", "Associated signed tag.") 

2334 

2335 gpgsig = serializable_property("gpgsig", "GPG Signature.") 

2336 

2337 

2338OBJECT_CLASSES = ( 

2339 Commit, 

2340 Tree, 

2341 Blob, 

2342 Tag, 

2343) 

2344 

2345_TYPE_MAP: dict[bytes | int, type[ShaFile]] = {} 

2346 

2347for cls in OBJECT_CLASSES: 

2348 _TYPE_MAP[cls.type_name] = cls 

2349 _TYPE_MAP[cls.type_num] = cls 

2350 

2351 

2352# Public API functions 

2353 

2354 

2355def parse_commit_broken(data: bytes) -> Commit: 

2356 """Parse a commit with broken author/committer lines. 

2357 

2358 This function handles various broken formats found in the wild: 

2359 - Missing angle brackets around email addresses 

2360 - Unsigned timezones (e.g., "0000" instead of "+0000") 

2361 - Double-negative timezones (e.g., "--700") 

2362 - Negative timestamps 

2363 - Long/short/nonsensical timezone values 

2364 

2365 Warning: Commits parsed with this function may not round-trip correctly 

2366 through serialization, as the broken formatting is normalized during parsing. 

2367 The .check() method will likely fail for commits with malformed identity fields. 

2368 

2369 Args: 

2370 data: Raw commit data as bytes 

2371 

2372 Returns: 

2373 A Commit object with normalized fields 

2374 

2375 Example: 

2376 >>> data = b'''tree d80c186a03f423a81b39df39dc87fd269736ca86 

2377 ... author user@example.com 1234567890 -0500 

2378 ... committer user@example.com 1234567890 -0500 

2379 ... 

2380 ... Commit message 

2381 ... ''' 

2382 >>> commit = parse_commit_broken(data) 

2383 >>> commit.author 

2384 b'user@example.com' 

2385 """ 

2386 commit = Commit() 

2387 ( 

2388 tree, 

2389 parents, 

2390 author_info, 

2391 commit_info, 

2392 encoding, 

2393 mergetag, 

2394 gpgsig, 

2395 message, 

2396 extra, 

2397 ) = _parse_commit_broken([data]) 

2398 

2399 commit._tree = tree 

2400 commit._parents = [ObjectID(p) for p in parents] 

2401 commit._encoding = encoding 

2402 commit._mergetag = mergetag 

2403 commit._gpgsig = gpgsig 

2404 commit._message = message 

2405 commit._extra = extra 

2406 

2407 ( 

2408 commit._author, 

2409 commit._author_time, 

2410 (commit._author_timezone, commit._author_timezone_neg_utc), 

2411 ) = author_info 

2412 ( 

2413 commit._committer, 

2414 commit._commit_time, 

2415 (commit._commit_timezone, commit._commit_timezone_neg_utc), 

2416 ) = commit_info 

2417 

2418 return commit 

2419 

2420 

2421# Hold on to the pure-python implementations for testing 

2422_parse_tree_py = parse_tree 

2423_sorted_tree_items_py = sorted_tree_items 

2424try: 

2425 # Try to import Rust versions 

2426 from dulwich._objects import ( 

2427 parse_tree as _parse_tree_rs, 

2428 ) 

2429 from dulwich._objects import ( 

2430 sorted_tree_items as _sorted_tree_items_rs, 

2431 ) 

2432except ImportError: 

2433 pass 

2434else: 

2435 parse_tree = _parse_tree_rs 

2436 sorted_tree_items = _sorted_tree_items_rs