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

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

948 statements  

1# patch.py -- For dealing with packed-style patches. 

2# Copyright (C) 2009-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"""Classes for dealing with git am-style patches. 

23 

24These patches are basically unified diffs with some extra metadata tacked 

25on. 

26""" 

27 

28__all__ = [ 

29 "DEFAULT_DIFF_ALGORITHM", 

30 "FIRST_FEW_BYTES", 

31 "DiffAlgorithmNotAvailable", 

32 "FilePatch", 

33 "MailinfoResult", 

34 "PatchApplicationFailure", 

35 "PatchHunk", 

36 "apply_patch_hunks", 

37 "apply_patches", 

38 "commit_patch_id", 

39 "gen_diff_header", 

40 "get_summary", 

41 "git_am_patch_split", 

42 "git_base85_decode", 

43 "is_binary", 

44 "mailinfo", 

45 "parse_patch_message", 

46 "parse_unified_diff", 

47 "patch_filename", 

48 "patch_id", 

49 "shortid", 

50 "unified_diff", 

51 "unified_diff_with_algorithm", 

52 "write_blob_diff", 

53 "write_commit_diff", 

54 "write_commit_patch", 

55 "write_object_diff", 

56 "write_tree_diff", 

57] 

58 

59import base64 

60import email.message 

61import email.parser 

62import email.utils 

63import os 

64import re 

65import stat 

66import time 

67import zlib 

68from collections.abc import Generator, Sequence 

69from dataclasses import dataclass 

70from difflib import SequenceMatcher 

71from typing import ( 

72 IO, 

73 TYPE_CHECKING, 

74 BinaryIO, 

75 TextIO, 

76) 

77 

78if TYPE_CHECKING: 

79 from .config import Config 

80 from .object_store import BaseObjectStore 

81 from .repo import Repo 

82 

83from .objects import S_ISGITLINK, Blob, Commit, ObjectID, RawObjectID 

84 

85FIRST_FEW_BYTES = 8000 

86 

87DEFAULT_DIFF_ALGORITHM = "myers" 

88 

89 

90class PatchApplicationFailure(Exception): 

91 """Raised when a patch does not apply cleanly.""" 

92 

93 

94class DiffAlgorithmNotAvailable(Exception): 

95 """Raised when a requested diff algorithm is not available.""" 

96 

97 def __init__(self, algorithm: str, install_hint: str = "") -> None: 

98 """Initialize exception. 

99 

100 Args: 

101 algorithm: Name of the unavailable algorithm 

102 install_hint: Optional installation hint 

103 """ 

104 self.algorithm = algorithm 

105 self.install_hint = install_hint 

106 if install_hint: 

107 super().__init__( 

108 f"Diff algorithm '{algorithm}' requested but not available. {install_hint}" 

109 ) 

110 else: 

111 super().__init__( 

112 f"Diff algorithm '{algorithm}' requested but not available." 

113 ) 

114 

115 

116def write_commit_patch( 

117 f: IO[bytes], 

118 commit: "Commit", 

119 contents: str | bytes, 

120 progress: tuple[int, int], 

121 version: str | None = None, 

122 encoding: str | None = None, 

123) -> None: 

124 """Write a individual file patch. 

125 

126 Args: 

127 f: File-like object to write to 

128 commit: Commit object 

129 contents: Contents of the patch 

130 progress: tuple with current patch number and total. 

131 version: Version string to include in patch header 

132 encoding: Encoding to use for the patch 

133 

134 Returns: 

135 tuple with filename and contents 

136 """ 

137 encoding = encoding or getattr(f, "encoding", "ascii") 

138 if encoding is None: 

139 encoding = "ascii" 

140 if isinstance(contents, str): 

141 contents = contents.encode(encoding) 

142 (num, total) = progress 

143 f.write( 

144 b"From " 

145 + commit.id 

146 + b" " 

147 + time.ctime(commit.commit_time).encode(encoding) 

148 + b"\n" 

149 ) 

150 f.write(b"From: " + commit.author + b"\n") 

151 f.write( 

152 b"Date: " + time.strftime("%a, %d %b %Y %H:%M:%S %Z").encode(encoding) + b"\n" 

153 ) 

154 # The first paragraph becomes the Subject, folded onto a single line; the 

155 # rest of the message is written as the mail body. 

156 subject, _, body = commit.message.partition(b"\n\n") 

157 subject = subject.replace(b"\n", b" ").strip() 

158 f.write((f"Subject: [PATCH {num}/{total}] ").encode(encoding) + subject + b"\n") 

159 f.write(b"\n") 

160 if body: 

161 f.write(body) 

162 if not body.endswith(b"\n"): 

163 f.write(b"\n") 

164 f.write(b"---\n") 

165 try: 

166 import subprocess 

167 

168 p = subprocess.Popen( 

169 ["diffstat"], stdout=subprocess.PIPE, stdin=subprocess.PIPE 

170 ) 

171 except (ImportError, OSError): 

172 pass # diffstat not available? 

173 else: 

174 (diffstat, _) = p.communicate(contents) 

175 f.write(diffstat) 

176 f.write(b"\n") 

177 f.write(contents) 

178 f.write(b"-- \n") 

179 if version is None: 

180 from dulwich import __version__ as dulwich_version 

181 

182 f.write(b"Dulwich %d.%d.%d\n" % dulwich_version) 

183 else: 

184 if encoding is None: 

185 encoding = "ascii" 

186 f.write(version.encode(encoding) + b"\n") 

187 

188 

189def _sanitize_subject_for_filename(text: str, max_length: int = 52) -> str: 

190 """Sanitize a string for safe use as part of a filename. 

191 

192 Matches git's ``format_sanitized_subject`` behavior: 

193 

194 - Only ``[A-Za-z0-9._]`` are kept; other characters become ``-`` 

195 (collapsed across runs). 

196 - Consecutive ``.`` are collapsed to a single ``.``. 

197 - The result is truncated to ``max_length`` characters. 

198 - Trailing ``.`` and ``-`` are stripped. 

199 

200 Args: 

201 text: Input string (typically a commit subject line). 

202 max_length: Maximum length of the returned string. 

203 

204 Returns: Sanitized string safe to embed in a filename. 

205 """ 

206 result: list[str] = [] 

207 # 2 = initial, 1 = saw a non-title char, 0 = saw a title char 

208 space = 2 

209 i = 0 

210 text_len = len(text) 

211 while i < text_len: 

212 c = text[i] 

213 if ("A" <= c <= "Z") or ("a" <= c <= "z") or ("0" <= c <= "9") or c in "._": 

214 if space == 1: 

215 result.append("-") 

216 space = 0 

217 result.append(c) 

218 if c == ".": 

219 while i + 1 < text_len and text[i + 1] == ".": 

220 i += 1 

221 else: 

222 space |= 1 

223 i += 1 

224 if len(result) >= max_length: 

225 break 

226 

227 return "".join(result)[:max_length].rstrip(".-") 

228 

229 

230def get_summary(commit: "Commit") -> str: 

231 """Determine the summary line for use in a filename. 

232 

233 Sanitizes the commit subject so it is safe to use as a filename 

234 component, matching git's ``format_sanitized_subject`` behavior: 

235 characters outside ``[A-Za-z0-9._]`` are replaced with ``-`` (with 

236 runs collapsed) and consecutive ``.`` are collapsed. The result is 

237 also length-limited to prevent overly long filenames. 

238 

239 Args: 

240 commit: Commit 

241 Returns: Sanitized summary string suitable for use as a filename 

242 component. 

243 """ 

244 decoded = commit.message.decode(errors="replace") 

245 lines = decoded.splitlines() 

246 if not lines: 

247 return "" 

248 return _sanitize_subject_for_filename(lines[0]) 

249 

250 

251# Unified Diff 

252def _format_range_unified(start: int, stop: int) -> str: 

253 """Convert range to the "ed" format.""" 

254 # Per the diff spec at http://www.unix.org/single_unix_specification/ 

255 beginning = start + 1 # lines start numbering with one 

256 length = stop - start 

257 if length == 1: 

258 return f"{beginning}" 

259 if not length: 

260 beginning -= 1 # empty ranges begin at line just before the range 

261 return f"{beginning},{length}" 

262 

263 

264def unified_diff( 

265 a: Sequence[bytes], 

266 b: Sequence[bytes], 

267 fromfile: bytes = b"", 

268 tofile: bytes = b"", 

269 fromfiledate: str = "", 

270 tofiledate: str = "", 

271 n: int = 3, 

272 lineterm: str = "\n", 

273 tree_encoding: str = "utf-8", 

274 output_encoding: str = "utf-8", 

275) -> Generator[bytes, None, None]: 

276 """difflib.unified_diff that can detect "No newline at end of file" as original "git diff" does. 

277 

278 Based on the same function in Python2.7 difflib.py 

279 """ 

280 started = False 

281 for group in SequenceMatcher(a=a, b=b).get_grouped_opcodes(n): 

282 if not started: 

283 started = True 

284 fromdate = f"\t{fromfiledate}" if fromfiledate else "" 

285 todate = f"\t{tofiledate}" if tofiledate else "" 

286 yield f"--- {fromfile.decode(tree_encoding)}{fromdate}{lineterm}".encode( 

287 output_encoding 

288 ) 

289 yield f"+++ {tofile.decode(tree_encoding)}{todate}{lineterm}".encode( 

290 output_encoding 

291 ) 

292 

293 first, last = group[0], group[-1] 

294 file1_range = _format_range_unified(first[1], last[2]) 

295 file2_range = _format_range_unified(first[3], last[4]) 

296 yield f"@@ -{file1_range} +{file2_range} @@{lineterm}".encode(output_encoding) 

297 

298 for tag, i1, i2, j1, j2 in group: 

299 if tag == "equal": 

300 for line in a[i1:i2]: 

301 yield b" " + line 

302 continue 

303 if tag in ("replace", "delete"): 

304 for line in a[i1:i2]: 

305 if not line[-1:] == b"\n": 

306 line += b"\n\\ No newline at end of file\n" 

307 yield b"-" + line 

308 if tag in ("replace", "insert"): 

309 for line in b[j1:j2]: 

310 if not line[-1:] == b"\n": 

311 line += b"\n\\ No newline at end of file\n" 

312 yield b"+" + line 

313 

314 

315def _get_sequence_matcher( 

316 algorithm: str, a: Sequence[bytes], b: Sequence[bytes] 

317) -> SequenceMatcher[bytes]: 

318 """Get appropriate sequence matcher for the given algorithm. 

319 

320 Args: 

321 algorithm: Diff algorithm ("myers" or "patience") 

322 a: First sequence 

323 b: Second sequence 

324 

325 Returns: 

326 Configured sequence matcher instance 

327 

328 Raises: 

329 DiffAlgorithmNotAvailable: If patience requested but not available 

330 """ 

331 if algorithm == "patience": 

332 try: 

333 from patiencediff import PatienceSequenceMatcher 

334 

335 return PatienceSequenceMatcher(None, a, b) # type: ignore[no-any-return,unused-ignore] 

336 except ImportError: 

337 raise DiffAlgorithmNotAvailable( 

338 "patience", "Install with: pip install 'dulwich[patiencediff]'" 

339 ) 

340 else: 

341 return SequenceMatcher(a=a, b=b) 

342 

343 

344def unified_diff_with_algorithm( 

345 a: Sequence[bytes], 

346 b: Sequence[bytes], 

347 fromfile: bytes = b"", 

348 tofile: bytes = b"", 

349 fromfiledate: str = "", 

350 tofiledate: str = "", 

351 n: int = 3, 

352 lineterm: str = "\n", 

353 tree_encoding: str = "utf-8", 

354 output_encoding: str = "utf-8", 

355 algorithm: str | None = None, 

356) -> Generator[bytes, None, None]: 

357 """Generate unified diff with specified algorithm. 

358 

359 Args: 

360 a: First sequence of lines 

361 b: Second sequence of lines 

362 fromfile: Name of first file 

363 tofile: Name of second file 

364 fromfiledate: Date of first file 

365 tofiledate: Date of second file 

366 n: Number of context lines 

367 lineterm: Line terminator 

368 tree_encoding: Encoding for tree paths 

369 output_encoding: Encoding for output 

370 algorithm: Diff algorithm to use ("myers" or "patience") 

371 

372 Returns: 

373 Generator yielding diff lines 

374 

375 Raises: 

376 DiffAlgorithmNotAvailable: If patience algorithm requested but patiencediff not available 

377 """ 

378 if algorithm is None: 

379 algorithm = DEFAULT_DIFF_ALGORITHM 

380 

381 matcher = _get_sequence_matcher(algorithm, a, b) 

382 

383 started = False 

384 for group in matcher.get_grouped_opcodes(n): 

385 if not started: 

386 started = True 

387 fromdate = f"\t{fromfiledate}" if fromfiledate else "" 

388 todate = f"\t{tofiledate}" if tofiledate else "" 

389 yield f"--- {fromfile.decode(tree_encoding)}{fromdate}{lineterm}".encode( 

390 output_encoding 

391 ) 

392 yield f"+++ {tofile.decode(tree_encoding)}{todate}{lineterm}".encode( 

393 output_encoding 

394 ) 

395 

396 first, last = group[0], group[-1] 

397 file1_range = _format_range_unified(first[1], last[2]) 

398 file2_range = _format_range_unified(first[3], last[4]) 

399 yield f"@@ -{file1_range} +{file2_range} @@{lineterm}".encode(output_encoding) 

400 

401 for tag, i1, i2, j1, j2 in group: 

402 if tag == "equal": 

403 for line in a[i1:i2]: 

404 yield b" " + line 

405 continue 

406 if tag in ("replace", "delete"): 

407 for line in a[i1:i2]: 

408 if not line[-1:] == b"\n": 

409 line += b"\n\\ No newline at end of file\n" 

410 yield b"-" + line 

411 if tag in ("replace", "insert"): 

412 for line in b[j1:j2]: 

413 if not line[-1:] == b"\n": 

414 line += b"\n\\ No newline at end of file\n" 

415 yield b"+" + line 

416 

417 

418def is_binary(content: bytes) -> bool: 

419 """See if the first few bytes contain any null characters. 

420 

421 Args: 

422 content: Bytestring to check for binary content 

423 """ 

424 return b"\0" in content[:FIRST_FEW_BYTES] 

425 

426 

427def shortid(hexsha: bytes | None) -> bytes: 

428 """Get short object ID. 

429 

430 Args: 

431 hexsha: Full hex SHA or None 

432 

433 Returns: 

434 7-character short ID 

435 """ 

436 if hexsha is None: 

437 return b"0" * 7 

438 else: 

439 return hexsha[:7] 

440 

441 

442def patch_filename(p: bytes | None, root: bytes) -> bytes: 

443 """Generate patch filename. 

444 

445 Args: 

446 p: Path or None 

447 root: Root directory 

448 

449 Returns: 

450 Full patch filename 

451 """ 

452 if p is None: 

453 return b"/dev/null" 

454 else: 

455 return root + b"/" + p 

456 

457 

458def write_object_diff( 

459 f: IO[bytes], 

460 store: "BaseObjectStore", 

461 old_file: tuple[bytes | None, int | None, ObjectID | None], 

462 new_file: tuple[bytes | None, int | None, ObjectID | None], 

463 diff_binary: bool = False, 

464 diff_algorithm: str | None = None, 

465) -> None: 

466 """Write the diff for an object. 

467 

468 Args: 

469 f: File-like object to write to 

470 store: Store to retrieve objects from, if necessary 

471 old_file: (path, mode, hexsha) tuple 

472 new_file: (path, mode, hexsha) tuple 

473 diff_binary: Whether to diff files even if they 

474 are considered binary files by is_binary(). 

475 diff_algorithm: Algorithm to use for diffing ("myers" or "patience") 

476 

477 Note: the tuple elements should be None for nonexistent files 

478 """ 

479 (old_path, old_mode, old_id) = old_file 

480 (new_path, new_mode, new_id) = new_file 

481 patched_old_path = patch_filename(old_path, b"a") 

482 patched_new_path = patch_filename(new_path, b"b") 

483 

484 def content(mode: int | None, hexsha: ObjectID | None) -> Blob: 

485 """Get blob content for a file. 

486 

487 Args: 

488 mode: File mode 

489 hexsha: Object SHA 

490 

491 Returns: 

492 Blob object 

493 """ 

494 if hexsha is None: 

495 return Blob.from_string(b"") 

496 elif mode is not None and S_ISGITLINK(mode): 

497 return Blob.from_string(b"Subproject commit " + hexsha + b"\n") 

498 else: 

499 obj = store[hexsha] 

500 if isinstance(obj, Blob): 

501 return obj 

502 else: 

503 # Fallback for non-blob objects 

504 return Blob.from_string(obj.as_raw_string()) 

505 

506 def lines(content: "Blob") -> list[bytes]: 

507 """Split blob content into lines. 

508 

509 Args: 

510 content: Blob content 

511 

512 Returns: 

513 List of lines 

514 """ 

515 if not content: 

516 return [] 

517 else: 

518 return content.splitlines() 

519 

520 f.writelines( 

521 gen_diff_header((old_path, new_path), (old_mode, new_mode), (old_id, new_id)) 

522 ) 

523 old_content = content(old_mode, old_id) 

524 new_content = content(new_mode, new_id) 

525 if not diff_binary and (is_binary(old_content.data) or is_binary(new_content.data)): 

526 binary_diff = ( 

527 b"Binary files " 

528 + patched_old_path 

529 + b" and " 

530 + patched_new_path 

531 + b" differ\n" 

532 ) 

533 f.write(binary_diff) 

534 else: 

535 f.writelines( 

536 unified_diff_with_algorithm( 

537 lines(old_content), 

538 lines(new_content), 

539 patched_old_path, 

540 patched_new_path, 

541 algorithm=diff_algorithm, 

542 ) 

543 ) 

544 

545 

546# TODO(jelmer): Support writing unicode, rather than bytes. 

547def gen_diff_header( 

548 paths: tuple[bytes | None, bytes | None], 

549 modes: tuple[int | None, int | None], 

550 shas: tuple[bytes | None, bytes | None], 

551) -> Generator[bytes, None, None]: 

552 """Write a blob diff header. 

553 

554 Args: 

555 paths: Tuple with old and new path 

556 modes: Tuple with old and new modes 

557 shas: Tuple with old and new shas 

558 """ 

559 (old_path, new_path) = paths 

560 (old_mode, new_mode) = modes 

561 (old_sha, new_sha) = shas 

562 if old_path is None and new_path is not None: 

563 old_path = new_path 

564 if new_path is None and old_path is not None: 

565 new_path = old_path 

566 old_path = patch_filename(old_path, b"a") 

567 new_path = patch_filename(new_path, b"b") 

568 yield b"diff --git " + old_path + b" " + new_path + b"\n" 

569 

570 if old_mode != new_mode: 

571 if new_mode is not None: 

572 if old_mode is not None: 

573 yield (f"old file mode {old_mode:o}\n").encode("ascii") 

574 yield (f"new file mode {new_mode:o}\n").encode("ascii") 

575 else: 

576 yield (f"deleted file mode {old_mode:o}\n").encode("ascii") 

577 yield b"index " + shortid(old_sha) + b".." + shortid(new_sha) 

578 if new_mode is not None and old_mode is not None: 

579 yield (f" {new_mode:o}").encode("ascii") 

580 yield b"\n" 

581 

582 

583# TODO(jelmer): Support writing unicode, rather than bytes. 

584def write_blob_diff( 

585 f: IO[bytes], 

586 old_file: tuple[bytes | None, int | None, "Blob | None"], 

587 new_file: tuple[bytes | None, int | None, "Blob | None"], 

588 diff_algorithm: str | None = None, 

589) -> None: 

590 """Write blob diff. 

591 

592 Args: 

593 f: File-like object to write to 

594 old_file: (path, mode, hexsha) tuple (None if nonexisting) 

595 new_file: (path, mode, hexsha) tuple (None if nonexisting) 

596 diff_algorithm: Algorithm to use for diffing ("myers" or "patience") 

597 

598 Note: The use of write_object_diff is recommended over this function. 

599 """ 

600 (old_path, old_mode, old_blob) = old_file 

601 (new_path, new_mode, new_blob) = new_file 

602 patched_old_path = patch_filename(old_path, b"a") 

603 patched_new_path = patch_filename(new_path, b"b") 

604 

605 def lines(blob: "Blob | None") -> list[bytes]: 

606 """Split blob content into lines. 

607 

608 Args: 

609 blob: Blob object or None 

610 

611 Returns: 

612 List of lines 

613 """ 

614 if blob is not None: 

615 return blob.splitlines() 

616 else: 

617 return [] 

618 

619 f.writelines( 

620 gen_diff_header( 

621 (old_path, new_path), 

622 (old_mode, new_mode), 

623 (getattr(old_blob, "id", None), getattr(new_blob, "id", None)), 

624 ) 

625 ) 

626 old_contents = lines(old_blob) 

627 new_contents = lines(new_blob) 

628 f.writelines( 

629 unified_diff_with_algorithm( 

630 old_contents, 

631 new_contents, 

632 patched_old_path, 

633 patched_new_path, 

634 algorithm=diff_algorithm, 

635 ) 

636 ) 

637 

638 

639def write_tree_diff( 

640 f: IO[bytes], 

641 store: "BaseObjectStore", 

642 old_tree: ObjectID | None, 

643 new_tree: ObjectID | None, 

644 diff_binary: bool = False, 

645 diff_algorithm: str | None = None, 

646) -> None: 

647 """Write tree diff. 

648 

649 Args: 

650 f: File-like object to write to. 

651 store: Object store to read from 

652 old_tree: Old tree id 

653 new_tree: New tree id 

654 diff_binary: Whether to diff files even if they 

655 are considered binary files by is_binary(). 

656 diff_algorithm: Algorithm to use for diffing ("myers" or "patience") 

657 """ 

658 changes = store.tree_changes(old_tree, new_tree) 

659 for (oldpath, newpath), (oldmode, newmode), (oldsha, newsha) in changes: 

660 write_object_diff( 

661 f, 

662 store, 

663 (oldpath, oldmode, oldsha), 

664 (newpath, newmode, newsha), 

665 diff_binary=diff_binary, 

666 diff_algorithm=diff_algorithm, 

667 ) 

668 

669 

670def write_commit_diff( 

671 f: IO[bytes], 

672 store: "BaseObjectStore", 

673 commit: "Commit", 

674 diff_binary: bool = False, 

675 diff_algorithm: str | None = None, 

676) -> None: 

677 """Write the diff a commit introduces against its first parent. 

678 

679 Args: 

680 f: File-like object to write to. 

681 store: Object store to read from. 

682 commit: Commit whose changes to diff. Root commits (with no parents) 

683 are diffed against the empty tree. 

684 diff_binary: Whether to diff files even if they are considered binary 

685 files by is_binary(). 

686 diff_algorithm: Algorithm to use for diffing ("myers" or "patience"). 

687 """ 

688 if commit.parents: 

689 parent = store[commit.parents[0]] 

690 assert isinstance(parent, Commit) 

691 parent_tree: ObjectID | None = parent.tree 

692 else: 

693 parent_tree = None 

694 write_tree_diff( 

695 f, 

696 store, 

697 parent_tree, 

698 commit.tree, 

699 diff_binary=diff_binary, 

700 diff_algorithm=diff_algorithm, 

701 ) 

702 

703 

704def git_am_patch_split( 

705 f: TextIO | BinaryIO, encoding: str | None = None 

706) -> tuple["Commit", bytes, bytes | None]: 

707 """Parse a git-am-style patch and split it up into bits. 

708 

709 Args: 

710 f: File-like object to parse 

711 encoding: Encoding to use when creating Git objects 

712 Returns: Tuple with commit object, diff contents and git version 

713 """ 

714 encoding = encoding or getattr(f, "encoding", "ascii") 

715 encoding = encoding or "ascii" 

716 contents = f.read() 

717 if isinstance(contents, bytes): 

718 bparser = email.parser.BytesParser() 

719 msg = bparser.parsebytes(contents) 

720 else: 

721 uparser = email.parser.Parser() 

722 msg = uparser.parsestr(contents) 

723 return parse_patch_message(msg, encoding) 

724 

725 

726def parse_patch_message( 

727 msg: email.message.Message, encoding: str | None = None 

728) -> tuple["Commit", bytes, bytes | None]: 

729 """Extract a Commit object and patch from an e-mail message. 

730 

731 Args: 

732 msg: An email message (email.message.Message) 

733 encoding: Encoding to use to encode Git commits 

734 Returns: Tuple with commit object, diff contents and git version 

735 """ 

736 c = Commit() 

737 if encoding is None: 

738 encoding = "ascii" 

739 c.author = msg["from"].encode(encoding) 

740 c.committer = msg["from"].encode(encoding) 

741 try: 

742 patch_tag_start = msg["subject"].index("[PATCH") 

743 except ValueError: 

744 subject = msg["subject"] 

745 else: 

746 close = msg["subject"].index("] ", patch_tag_start) 

747 subject = msg["subject"][close + 2 :] 

748 c.message = (subject.replace("\n", "") + "\n").encode(encoding) 

749 first = True 

750 

751 body = msg.get_payload(decode=True) 

752 if isinstance(body, str): 

753 body = body.encode(encoding) 

754 if isinstance(body, bytes): 

755 lines = body.splitlines(True) 

756 else: 

757 # Handle other types by converting to string first 

758 lines = str(body).encode(encoding).splitlines(True) 

759 line_iter = iter(lines) 

760 

761 for line in line_iter: 

762 if line == b"---\n": 

763 break 

764 if first: 

765 if line.startswith(b"From: "): 

766 c.author = line[len(b"From: ") :].rstrip() 

767 else: 

768 c.message += b"\n" + line 

769 first = False 

770 else: 

771 c.message += line 

772 diff = b"" 

773 for line in line_iter: 

774 if line == b"-- \n": 

775 break 

776 diff += line 

777 try: 

778 version = next(line_iter).rstrip(b"\n") 

779 except StopIteration: 

780 version = None 

781 return c, diff, version 

782 

783 

784def patch_id(diff_data: bytes) -> bytes: 

785 """Compute patch ID for a diff. 

786 

787 The patch ID is computed by normalizing the diff and computing a SHA1 hash. 

788 This follows git's patch-id algorithm which: 

789 1. Removes whitespace from lines starting with + or - 

790 2. Replaces line numbers in @@ headers with a canonical form 

791 3. Computes SHA1 of the result 

792 

793 Args: 

794 diff_data: Raw diff data as bytes 

795 

796 Returns: 

797 SHA1 hash of normalized diff (40-byte hex string) 

798 

799 TODO: This implementation uses a simple line-by-line approach. For better 

800 compatibility with git's patch-id, consider using proper patch parsing that: 

801 - Handles edge cases in diff format (binary diffs, mode changes, etc.) 

802 - Properly parses unified diff format according to the spec 

803 - Matches git's exact normalization algorithm byte-for-byte 

804 See git's patch-id.c for reference implementation. 

805 """ 

806 import hashlib 

807 import re 

808 

809 # Normalize the diff for patch-id computation 

810 normalized_lines = [] 

811 

812 for line in diff_data.split(b"\n"): 

813 # Skip diff headers (diff --git, index, ---, +++) 

814 if line.startswith( 

815 ( 

816 b"diff --git ", 

817 b"index ", 

818 b"--- ", 

819 b"+++ ", 

820 b"new file mode ", 

821 b"old file mode ", 

822 b"deleted file mode ", 

823 b"new mode ", 

824 b"old mode ", 

825 b"similarity index ", 

826 b"dissimilarity index ", 

827 b"rename from ", 

828 b"rename to ", 

829 b"copy from ", 

830 b"copy to ", 

831 ) 

832 ): 

833 continue 

834 

835 # Normalize @@ headers to a canonical form 

836 if line.startswith(b"@@"): 

837 # Replace line numbers with canonical form 

838 match = re.match(rb"^@@\s+-\d+(?:,\d+)?\s+\+\d+(?:,\d+)?\s+@@", line) 

839 if match: 

840 # Use canonical hunk header without line numbers 

841 normalized_lines.append(b"@@") 

842 continue 

843 

844 # For +/- lines, strip all whitespace 

845 if line.startswith((b"+", b"-")): 

846 # Keep the +/- prefix but remove all whitespace from the rest 

847 if len(line) > 1: 

848 # Remove all whitespace from the content 

849 content = line[1:].replace(b" ", b"").replace(b"\t", b"") 

850 normalized_lines.append(line[:1] + content) 

851 else: 

852 # Just +/- alone 

853 normalized_lines.append(line[:1]) 

854 continue 

855 

856 # Keep context lines and other content as-is 

857 if line.startswith(b" ") or line == b"": 

858 normalized_lines.append(line) 

859 

860 # Join normalized lines and compute SHA1 

861 normalized = b"\n".join(normalized_lines) 

862 return hashlib.sha1(normalized).hexdigest().encode("ascii") 

863 

864 

865def commit_patch_id( 

866 store: "BaseObjectStore", commit_id: ObjectID | RawObjectID 

867) -> bytes: 

868 """Compute patch ID for a commit. 

869 

870 Args: 

871 store: Object store to read objects from 

872 commit_id: Commit ID (40-byte hex string) 

873 

874 Returns: 

875 Patch ID (40-byte hex string) 

876 """ 

877 from io import BytesIO 

878 

879 commit = store[commit_id] 

880 assert isinstance(commit, Commit) 

881 

882 diff_output = BytesIO() 

883 write_commit_diff(diff_output, store, commit) 

884 

885 return patch_id(diff_output.getvalue()) 

886 

887 

888@dataclass 

889class MailinfoResult: 

890 """Result of mailinfo parsing. 

891 

892 Attributes: 

893 author_name: Author's name 

894 author_email: Author's email address 

895 author_date: Author's date (if present in the email) 

896 subject: Processed subject line 

897 message: Commit message body 

898 patch: Patch content 

899 message_id: Message-ID header (if -m/--message-id was used) 

900 """ 

901 

902 author_name: str 

903 author_email: str 

904 author_date: str | None 

905 subject: str 

906 message: str 

907 patch: str 

908 message_id: str | None = None 

909 

910 

911def _munge_subject(subject: str, keep_subject: bool, keep_non_patch: bool) -> str: 

912 """Munge email subject line for commit message. 

913 

914 Args: 

915 subject: Original subject line 

916 keep_subject: If True, keep subject intact (-k option) 

917 keep_non_patch: If True, only strip [PATCH] (-b option) 

918 

919 Returns: 

920 Processed subject line 

921 """ 

922 if keep_subject: 

923 return subject 

924 

925 result = subject 

926 

927 # First remove Re: prefixes (they can appear before brackets) 

928 while True: 

929 new_result = re.sub(r"^\s*(?:re|RE|Re):\s*", "", result, flags=re.IGNORECASE) 

930 if new_result == result: 

931 break 

932 result = new_result 

933 

934 # Remove bracketed strings 

935 if keep_non_patch: 

936 # Only remove brackets containing "PATCH" 

937 # Match each bracket individually anywhere in the string 

938 while True: 

939 # Remove PATCH bracket, but be careful with whitespace 

940 new_result = re.sub( 

941 r"\[[^\]]*?PATCH[^\]]*?\](\s+)?", r"\1", result, flags=re.IGNORECASE 

942 ) 

943 if new_result == result: 

944 break 

945 result = new_result 

946 else: 

947 # Remove all bracketed strings 

948 while True: 

949 new_result = re.sub(r"^\s*\[.*?\]\s*", "", result) 

950 if new_result == result: 

951 break 

952 result = new_result 

953 

954 # Remove leading/trailing whitespace 

955 result = result.strip() 

956 

957 # Normalize multiple whitespace to single space 

958 result = re.sub(r"\s+", " ", result) 

959 

960 return result 

961 

962 

963def _find_scissors_line(lines: list[bytes]) -> int | None: 

964 """Find the scissors line in message body. 

965 

966 Args: 

967 lines: List of lines in the message body 

968 

969 Returns: 

970 Index of scissors line, or None if not found 

971 """ 

972 # The perforation alternatives are written so no two unbounded ``-+`` 

973 # quantifiers are separated by an optional/empty subpattern. The earlier 

974 # form ``(?:>?\s*-+\s*)?(?:8<|>8)?\s*-+\s*`` let a single run of dashes be 

975 # split between two ``-+`` groups, so a long non-matching line of dashes 

976 # backtracked quadratically (ReDoS) when fed through ``mailinfo`` / 

977 # ``git am --scissors`` on an untrusted mailbox. Here a marker is the 

978 # mandatory pivot between the two dash runs, and the marker-less case uses a 

979 # bounded ``(?:\s+-+)?`` second run, keeping the match linear. 

980 scissors_pattern = re.compile( 

981 rb"^>?\s*-+(?:\s+-+)?\s*$" 

982 rb"|^>?\s*(?:-+\s*)?(?:8<|>8)\s*-+\s*$" 

983 rb"|^(?:>?\s*-+\s*)(?:cut here|scissors)(?:\s*-+)?$", 

984 re.IGNORECASE, 

985 ) 

986 

987 for i, line in enumerate(lines): 

988 if scissors_pattern.match(line.strip()): 

989 return i 

990 

991 return None 

992 

993 

994def git_base85_decode(data: bytes) -> bytes: 

995 """Decode Git's base85-encoded binary data. 

996 

997 Each line starts with a byte giving the decoded length of that line 

998 (``A``-``Z`` for 1-26, ``a``-``z`` for 27-52), followed by groups of five 

999 base85 characters that each encode four bytes. Git's alphabet is the 

1000 RFC 1924 one used by ``base64.b85decode``. 

1001 

1002 Args: 

1003 data: Base85-encoded data as bytes (may contain multiple lines) 

1004 

1005 Returns: 

1006 Decoded binary data 

1007 

1008 Raises: 

1009 ValueError: If the data is invalid 

1010 """ 

1011 result = bytearray() 

1012 for line in data.splitlines(): 

1013 if not line: 

1014 continue 

1015 length_byte = line[0] 

1016 if ord("A") <= length_byte <= ord("Z"): 

1017 length = length_byte - ord("A") + 1 

1018 elif ord("a") <= length_byte <= ord("z"): 

1019 length = length_byte - ord("a") + 27 

1020 else: 

1021 raise ValueError(f"Invalid base85 line length byte: {line[:1]!r}") 

1022 encoded = line[1:] 

1023 if len(encoded) != (length + 3) // 4 * 5: 

1024 raise ValueError( 

1025 f"Base85 line has {len(encoded)} characters, " 

1026 f"expected {(length + 3) // 4 * 5} for {length} bytes" 

1027 ) 

1028 result.extend(base64.b85decode(encoded)[:length]) 

1029 return bytes(result) 

1030 

1031 

1032@dataclass 

1033class PatchHunk: 

1034 """Represents a single hunk in a unified diff. 

1035 

1036 Attributes: 

1037 old_start: Starting line number in old file 

1038 old_count: Number of lines in old file 

1039 new_start: Starting line number in new file 

1040 new_count: Number of lines in new file 

1041 lines: List of diff lines (prefixed with ' ', '+', or '-') 

1042 """ 

1043 

1044 old_start: int 

1045 old_count: int 

1046 new_start: int 

1047 new_count: int 

1048 lines: list[bytes] 

1049 

1050 

1051@dataclass 

1052class FilePatch: 

1053 """Represents a patch for a single file. 

1054 

1055 Attributes: 

1056 old_path: Path to old file (None for new files) 

1057 new_path: Path to new file (None for deleted files) 

1058 old_mode: Mode of old file (None for new files) 

1059 new_mode: Mode of new file (None for deleted files) 

1060 hunks: List of PatchHunk objects 

1061 binary: True if this is a binary patch 

1062 rename_from: Original path for renames (None if not a rename) 

1063 rename_to: New path for renames (None if not a rename) 

1064 copy_from: Source path for copies (None if not a copy) 

1065 copy_to: Destination path for copies (None if not a copy) 

1066 binary_old: Reverse binary data for binary patches (base85 encoded) 

1067 binary_new: Forward binary data for binary patches (base85 encoded) 

1068 binary_old_delta: True if binary_old is a delta against the new 

1069 content rather than a literal copy of the old content 

1070 binary_new_delta: True if binary_new is a delta against the old 

1071 content rather than a literal copy of the new content 

1072 """ 

1073 

1074 old_path: bytes | None 

1075 new_path: bytes | None 

1076 old_mode: int | None 

1077 new_mode: int | None 

1078 hunks: list[PatchHunk] 

1079 binary: bool = False 

1080 rename_from: bytes | None = None 

1081 rename_to: bytes | None = None 

1082 copy_from: bytes | None = None 

1083 copy_to: bytes | None = None 

1084 binary_old: bytes | None = None 

1085 binary_new: bytes | None = None 

1086 binary_old_delta: bool = False 

1087 binary_new_delta: bool = False 

1088 

1089 

1090_C_STYLE_ESCAPES = { 

1091 ord("a"): b"\a", 

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

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

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

1095 ord("v"): b"\v", 

1096 ord("f"): b"\f", 

1097 ord("r"): b"\r", 

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

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

1100} 

1101 

1102 

1103def _unquote_c_style(text: bytes) -> tuple[bytes, bytes]: 

1104 """Unquote a C-style quoted name at the start of ``text``. 

1105 

1106 Git quotes names containing special or non-ASCII characters this way. 

1107 

1108 Returns: 

1109 Tuple with the unquoted name and the remainder of ``text`` after the 

1110 closing quote. 

1111 

1112 Raises: 

1113 ValueError: If the quoted name is malformed 

1114 """ 

1115 if not text.startswith(b'"'): 

1116 raise ValueError(f"Name is not quoted: {text!r}") 

1117 result = bytearray() 

1118 i = 1 

1119 while i < len(text): 

1120 c = text[i] 

1121 if c == ord('"'): 

1122 return bytes(result), text[i + 1 :] 

1123 if c != ord("\\"): 

1124 result.append(c) 

1125 i += 1 

1126 continue 

1127 escaped = text[i + 1 : i + 2] 

1128 if escaped and escaped[0] in _C_STYLE_ESCAPES: 

1129 result += _C_STYLE_ESCAPES[escaped[0]] 

1130 i += 2 

1131 elif re.fullmatch(rb"[0-3][0-7][0-7]", text[i + 1 : i + 4]): 

1132 result.append(int(text[i + 1 : i + 4], 8)) 

1133 i += 4 

1134 else: 

1135 raise ValueError(f"Invalid escape in quoted name: {text!r}") 

1136 raise ValueError(f"Unterminated quoted name: {text!r}") 

1137 

1138 

1139def _unquote_name(name: bytes) -> bytes: 

1140 """Unquote a name from a patch header if git quoted it.""" 

1141 if not name.startswith(b'"'): 

1142 return name 

1143 unquoted, rest = _unquote_c_style(name) 

1144 if rest: 

1145 raise ValueError(f"Trailing data after quoted name: {name!r}") 

1146 return unquoted 

1147 

1148 

1149def _parse_file_line_path(path: bytes) -> bytes: 

1150 """Parse the path from a ``---`` or ``+++`` line, dropping any timestamp.""" 

1151 if not path.startswith(b'"'): 

1152 return path.split(b"\t")[0] 

1153 unquoted, rest = _unquote_c_style(path) 

1154 if rest and not rest.startswith(b"\t"): 

1155 raise ValueError(f"Trailing data after quoted name: {path!r}") 

1156 return unquoted 

1157 

1158 

1159def _parse_git_diff_header_paths(line: bytes) -> tuple[bytes | None, bytes | None]: 

1160 """Extract the old and new paths from a ``diff --git`` line. 

1161 

1162 Unquoted names are only separable when they are the same apart from their 

1163 prefixes (as for anything but a rename or copy), in which case the line is 

1164 split in the middle. Returns ``(None, None)`` if the names differ. 

1165 """ 

1166 names = line[len(b"diff --git ") :] 

1167 if names.startswith(b'"'): 

1168 old, rest = _unquote_c_style(names) 

1169 if not rest.startswith(b" "): 

1170 return None, None 

1171 new = _unquote_name(rest[1:]) 

1172 else: 

1173 half = len(names) // 2 

1174 if len(names) % 2 != 1 or names[half : half + 1] != b" ": 

1175 return None, None 

1176 old, new = names[:half], names[half + 1 :] 

1177 if old.partition(b"/")[2] != new.partition(b"/")[2]: 

1178 return None, None 

1179 return old, new 

1180 

1181 

1182def parse_unified_diff(diff_text: bytes) -> list[FilePatch]: 

1183 """Parse a unified diff into FilePatch objects. 

1184 

1185 Args: 

1186 diff_text: Unified diff content as bytes 

1187 

1188 Returns: 

1189 List of FilePatch objects 

1190 """ 

1191 patches: list[FilePatch] = [] 

1192 lines = diff_text.split(b"\n") 

1193 i = 0 

1194 

1195 while i < len(lines): 

1196 line = lines[i] 

1197 

1198 # Look for diff header 

1199 if line.startswith(b"diff --git "): 

1200 # Parse file patch 

1201 old_path = None 

1202 new_path = None 

1203 old_mode = None 

1204 new_mode = None 

1205 hunks: list[PatchHunk] = [] 

1206 binary = False 

1207 rename_from = None 

1208 rename_to = None 

1209 copy_from = None 

1210 copy_to = None 

1211 binary_old = None 

1212 binary_new = None 

1213 binary_old_delta = False 

1214 binary_new_delta = False 

1215 header_old_path, header_new_path = _parse_git_diff_header_paths(line) 

1216 

1217 # Parse extended headers 

1218 i += 1 

1219 while i < len(lines): 

1220 line = lines[i] 

1221 

1222 if line.startswith(b"old file mode "): 

1223 old_mode = int(line.split()[-1], 8) 

1224 i += 1 

1225 elif line.startswith(b"new file mode "): 

1226 new_mode = int(line.split()[-1], 8) 

1227 header_old_path = None 

1228 i += 1 

1229 elif line.startswith(b"deleted file mode "): 

1230 old_mode = int(line.split()[-1], 8) 

1231 header_new_path = None 

1232 i += 1 

1233 elif line.startswith(b"new mode "): 

1234 new_mode = int(line.split()[-1], 8) 

1235 i += 1 

1236 elif line.startswith(b"old mode "): 

1237 old_mode = int(line.split()[-1], 8) 

1238 i += 1 

1239 elif line.startswith(b"rename from "): 

1240 rename_from = _unquote_name(line[12:].strip()) 

1241 i += 1 

1242 elif line.startswith(b"rename to "): 

1243 rename_to = _unquote_name(line[10:].strip()) 

1244 i += 1 

1245 elif line.startswith(b"copy from "): 

1246 copy_from = _unquote_name(line[10:].strip()) 

1247 i += 1 

1248 elif line.startswith(b"copy to "): 

1249 copy_to = _unquote_name(line[8:].strip()) 

1250 i += 1 

1251 elif line.startswith(b"similarity index "): 

1252 # Just skip similarity index for now 

1253 i += 1 

1254 elif line.startswith(b"dissimilarity index "): 

1255 # Just skip dissimilarity index for now 

1256 i += 1 

1257 elif line.startswith(b"index "): 

1258 # "index <old>..<new> <mode>" gives the mode when the 

1259 # patch doesn't change it. 

1260 fields = line.split() 

1261 if len(fields) == 3: 

1262 mode = int(fields[2], 8) 

1263 if old_mode is None: 

1264 old_mode = mode 

1265 if new_mode is None: 

1266 new_mode = mode 

1267 i += 1 

1268 elif line.startswith(b"--- "): 

1269 # Parse old file path 

1270 path = _parse_file_line_path(line[4:]) 

1271 if path != b"/dev/null": 

1272 old_path = path 

1273 i += 1 

1274 elif line.startswith(b"+++ "): 

1275 # Parse new file path 

1276 path = _parse_file_line_path(line[4:]) 

1277 if path != b"/dev/null": 

1278 new_path = path 

1279 i += 1 

1280 break 

1281 elif line.startswith(b"Binary files"): 

1282 binary = True 

1283 i += 1 

1284 break 

1285 elif line.startswith(b"GIT binary patch"): 

1286 binary = True 

1287 i += 1 

1288 # The forward data comes first, followed by the reverse 

1289 # data. Each is a "literal" or "delta" line, then base85 

1290 # lines up to a blank line. 

1291 blocks: list[tuple[bool, bytes]] = [] 

1292 while ( 

1293 i < len(lines) 

1294 and len(blocks) < 2 

1295 and lines[i].startswith((b"literal ", b"delta ")) 

1296 ): 

1297 is_delta = lines[i].startswith(b"delta ") 

1298 i += 1 

1299 block_start = i 

1300 while i < len(lines) and lines[i]: 

1301 i += 1 

1302 blocks.append( 

1303 ( 

1304 is_delta, 

1305 b"".join(data + b"\n" for data in lines[block_start:i]), 

1306 ) 

1307 ) 

1308 i += 1 

1309 if not blocks: 

1310 raise ValueError("GIT binary patch without data") 

1311 binary_new_delta, binary_new = blocks[0] 

1312 if len(blocks) > 1: 

1313 binary_old_delta, binary_old = blocks[1] 

1314 break 

1315 else: 

1316 # Leave the line (e.g. the next "diff --git") to the 

1317 # hunk parser below. 

1318 break 

1319 

1320 if old_path is None and new_path is None: 

1321 # Binary patches and those without content changes (mode 

1322 # changes, empty files) have no ---/+++ lines. 

1323 old_path = header_old_path 

1324 new_path = header_new_path 

1325 

1326 # Parse hunks 

1327 if not binary: 

1328 while i < len(lines): 

1329 line = lines[i] 

1330 

1331 if line.startswith(b"@@ "): 

1332 # Parse hunk header 

1333 match = re.match( 

1334 rb"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@", line 

1335 ) 

1336 if match: 

1337 old_start = int(match.group(1)) 

1338 old_count = int(match.group(2)) if match.group(2) else 1 

1339 new_start = int(match.group(3)) 

1340 new_count = int(match.group(4)) if match.group(4) else 1 

1341 

1342 # Parse hunk lines 

1343 hunk_lines: list[bytes] = [] 

1344 i += 1 

1345 while i < len(lines): 

1346 line = lines[i] 

1347 if line.startswith((b" ", b"+", b"-", b"\\")): 

1348 hunk_lines.append(line) 

1349 i += 1 

1350 else: 

1351 break 

1352 

1353 hunks.append( 

1354 PatchHunk( 

1355 old_start=old_start, 

1356 old_count=old_count, 

1357 new_start=new_start, 

1358 new_count=new_count, 

1359 lines=hunk_lines, 

1360 ) 

1361 ) 

1362 else: 

1363 i += 1 

1364 elif line.startswith(b"diff --git "): 

1365 # Next file patch 

1366 break 

1367 else: 

1368 i += 1 

1369 if not line.strip(): 

1370 # Empty line, might be end of patch or separator 

1371 break 

1372 

1373 patches.append( 

1374 FilePatch( 

1375 old_path=old_path, 

1376 new_path=new_path, 

1377 old_mode=old_mode, 

1378 new_mode=new_mode, 

1379 hunks=hunks, 

1380 binary=binary, 

1381 rename_from=rename_from, 

1382 rename_to=rename_to, 

1383 copy_from=copy_from, 

1384 copy_to=copy_to, 

1385 binary_old=binary_old, 

1386 binary_new=binary_new, 

1387 binary_old_delta=binary_old_delta, 

1388 binary_new_delta=binary_new_delta, 

1389 ) 

1390 ) 

1391 else: 

1392 i += 1 

1393 

1394 return patches 

1395 

1396 

1397def apply_patch_hunks( 

1398 patch: FilePatch, 

1399 original_lines: list[bytes], 

1400) -> list[bytes] | None: 

1401 """Apply patch hunks to file content. 

1402 

1403 Args: 

1404 patch: FilePatch object to apply 

1405 original_lines: Original file content as list of lines 

1406 

1407 Returns: 

1408 Patched file content as list of lines, or None if patch cannot be applied 

1409 """ 

1410 result = original_lines[:] 

1411 offset = 0 # Track line offset as we apply hunks 

1412 

1413 for hunk in patch.hunks: 

1414 # old_start is 1-indexed, except that a hunk without old lines 

1415 # inserts after line old_start. 

1416 if hunk.old_count == 0: 

1417 target_line = hunk.old_start + offset 

1418 else: 

1419 target_line = hunk.old_start - 1 + offset 

1420 

1421 # Extract old and new content from hunk 

1422 old_content: list[bytes] = [] 

1423 new_content: list[bytes] = [] 

1424 

1425 previous = b"" 

1426 for line in hunk.lines: 

1427 if line.startswith(b"\\"): 

1428 # "\ No newline at end of file" applies to the line before it 

1429 if previous in (b" ", b"-"): 

1430 old_content[-1] = old_content[-1].removesuffix(b"\n") 

1431 if previous in (b" ", b"+"): 

1432 new_content[-1] = new_content[-1].removesuffix(b"\n") 

1433 continue 

1434 previous = line[:1] 

1435 if line.startswith(b" "): 

1436 # Context line - add newline if not present 

1437 content = line[1:] 

1438 if not content.endswith(b"\n"): 

1439 content += b"\n" 

1440 old_content.append(content) 

1441 new_content.append(content) 

1442 elif line.startswith(b"-"): 

1443 # Deletion - add newline if not present 

1444 content = line[1:] 

1445 if not content.endswith(b"\n"): 

1446 content += b"\n" 

1447 old_content.append(content) 

1448 elif line.startswith(b"+"): 

1449 # Addition - add newline if not present 

1450 content = line[1:] 

1451 if not content.endswith(b"\n"): 

1452 content += b"\n" 

1453 new_content.append(content) 

1454 

1455 # Verify context matches 

1456 if target_line < 0 or target_line + len(old_content) > len(result): 

1457 # TODO: Implement fuzzy matching 

1458 return None 

1459 

1460 for i, old_line in enumerate(old_content): 

1461 if result[target_line + i] != old_line: 

1462 # Context doesn't match 

1463 # TODO: Implement fuzzy matching 

1464 return None 

1465 

1466 # Apply the patch 

1467 result[target_line : target_line + len(old_content)] = new_content 

1468 

1469 # Update offset for next hunk 

1470 offset += len(new_content) - len(old_content) 

1471 

1472 return result 

1473 

1474 

1475def _ensure_within_repo(repo_path: bytes, fs_path: bytes, rel_path: bytes) -> None: 

1476 """Reject patch target paths that resolve outside the working tree. 

1477 

1478 Patch headers are untrusted (e.g. ``git am`` of a mailbox), so a name such 

1479 as ``../../etc/cron.d/x`` or an absolute path must not be written through 

1480 ``os.path.join``. Mirrors git's refusal of paths outside the work tree. 

1481 """ 

1482 repo_root = os.path.realpath(repo_path) 

1483 resolved = os.path.realpath(fs_path) 

1484 if resolved != repo_root and not resolved.startswith( 

1485 repo_root + os.fsencode(os.sep) 

1486 ): 

1487 raise ValueError(f"patch affects file outside repository: {rel_path!r}") 

1488 

1489 

1490def _validate_patch_target(r: "Repo", repo_path: bytes, tree_path: bytes) -> bytes: 

1491 """Validate a patch target path and return its filesystem path. 

1492 

1493 ``_ensure_within_repo`` alone only refuses paths that escape the work tree; 

1494 a target like ``.git/hooks/pre-commit`` stays inside it and would otherwise 

1495 be written (and later executed as a hook). On top of that containment check, 

1496 apply the same name and symlink checks git uses for checkout, so patch 

1497 application cannot reach the control directory. Mirrors 

1498 ``porcelain._checked_worktree_path``. 

1499 

1500 Returns: 

1501 The filesystem path under ``repo_path``, as bytes. 

1502 """ 

1503 from .index import ( 

1504 InvalidPathError, 

1505 get_path_element_validator, 

1506 validate_path, 

1507 verify_leading_dirs, 

1508 ) 

1509 

1510 fs_path = os.path.join(repo_path, tree_path) 

1511 _ensure_within_repo(repo_path, fs_path, tree_path) 

1512 validator = get_path_element_validator(r.get_config_stack()) 

1513 if not validate_path(tree_path, validator): 

1514 raise ValueError(f"refusing to write unsafe path: {tree_path!r}") 

1515 try: 

1516 verify_leading_dirs(tree_path, [], repo_path) 

1517 except InvalidPathError: 

1518 raise ValueError(f"refusing to write through symlink: {tree_path!r}") 

1519 return fs_path 

1520 

1521 

1522def _read_patch_target(fs_path: bytes) -> tuple[bytes, int] | None: 

1523 """Read the content and mode of a patch target in the work tree. 

1524 

1525 A symlink is read with ``readlink`` rather than followed, since its 

1526 content in git is the link target. 

1527 

1528 Returns: 

1529 Tuple with content and mode, or None if the path does not exist 

1530 """ 

1531 from .index import cleanup_mode 

1532 

1533 try: 

1534 st = os.lstat(fs_path) 

1535 except FileNotFoundError: 

1536 return None 

1537 if stat.S_ISLNK(st.st_mode): 

1538 return os.readlink(fs_path), stat.S_IFLNK 

1539 with open(fs_path, "rb") as f: 

1540 return f.read(), cleanup_mode(st.st_mode) 

1541 

1542 

1543def _check_patch_target_type( 

1544 tree_path: bytes, disk_mode: int, expected_mode: int, config: "Config" 

1545) -> None: 

1546 """Refuse to apply a patch for a symlink to a file or vice versa. 

1547 

1548 ``verify_leading_dirs`` only checks the leading directories. A tracked 

1549 symlink such as ``trap -> .git/hooks/pre-commit`` resolves inside the work 

1550 tree, so writing a regular file patch to it would follow the link and land 

1551 in the control directory. git refuses such patches with "wrong type". 

1552 """ 

1553 if stat.S_ISLNK(disk_mode) == stat.S_ISLNK(expected_mode): 

1554 return 

1555 if ( 

1556 stat.S_ISLNK(expected_mode) 

1557 and not stat.S_ISLNK(disk_mode) 

1558 and not config.get_boolean(b"core", b"symlinks", True) 

1559 ): 

1560 # With core.symlinks=false links are checked out as plain files. 

1561 return 

1562 raise ValueError(f"wrong type for patch target: {tree_path!r}") 

1563 

1564 

1565def _write_patch_target( 

1566 fs_path: bytes, content: bytes, mode: int, config: "Config" 

1567) -> os.stat_result: 

1568 """Write a patch result to the work tree without following symlinks. 

1569 

1570 Returns: 

1571 The ``lstat`` result of the written path 

1572 """ 

1573 from .index import build_file_from_blob, get_symlink_fn 

1574 

1575 os.makedirs(os.path.dirname(fs_path), exist_ok=True) 

1576 return build_file_from_blob( 

1577 Blob.from_string(content), mode, fs_path, symlink_fn=get_symlink_fn(config) 

1578 ) 

1579 

1580 

1581def _refuse_existing_target(fs_path: bytes, tree_path: bytes) -> None: 

1582 """Refuse to create a file over an existing path, like git does.""" 

1583 if os.path.lexists(fs_path): 

1584 raise ValueError(f"{tree_path!r} already exists in working directory") 

1585 

1586 

1587def _load_patch_target( 

1588 r: "Repo", 

1589 fs_path: bytes, 

1590 tree_path: bytes, 

1591 expected_mode: int | None, 

1592 config: "Config", 

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

1594 """Load the current content and mode of an existing patch target. 

1595 

1596 Reads the work tree, falling back to the index if the path is missing 

1597 there. The mode from the patch, if any, must match the type on disk. 

1598 

1599 Returns: 

1600 Tuple with content and mode, or None if the path can't be found 

1601 """ 

1602 from .index import ConflictedIndexEntry, IndexEntry 

1603 

1604 def index_entry() -> IndexEntry | None: 

1605 try: 

1606 entry = r.open_index(config=config)[tree_path] 

1607 except (FileNotFoundError, KeyError): 

1608 return None 

1609 if isinstance(entry, ConflictedIndexEntry): 

1610 return None 

1611 return entry 

1612 

1613 current = _read_patch_target(fs_path) 

1614 if current is None: 

1615 entry = index_entry() 

1616 if entry is None: 

1617 return None 

1618 obj = r.object_store[entry.sha] 

1619 if not isinstance(obj, Blob): 

1620 return None 

1621 return obj.data, entry.mode 

1622 content, mode = current 

1623 if expected_mode is not None: 

1624 _check_patch_target_type(tree_path, mode, expected_mode, config) 

1625 return content, expected_mode 

1626 if not stat.S_ISLNK(mode) and not config.get_boolean(b"core", b"symlinks", True): 

1627 # A link checked out as a plain file is only recognizable as such 

1628 # from the index. 

1629 entry = index_entry() 

1630 if entry is not None and stat.S_ISLNK(entry.mode): 

1631 return content, entry.mode 

1632 return content, mode 

1633 

1634 

1635def _apply_rename_or_copy( 

1636 r: "Repo", 

1637 src_path: bytes, 

1638 dst_path: bytes, 

1639 strip: int, 

1640 patch: FilePatch, 

1641 is_rename: bool, 

1642 cached: bool, 

1643 check: bool, 

1644 config: "Config", 

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

1646 """Apply a rename or copy operation. 

1647 

1648 Args: 

1649 r: Repository object 

1650 src_path: Source path 

1651 dst_path: Destination path 

1652 strip: Number of path components to strip 

1653 patch: FilePatch object 

1654 is_rename: True for rename, False for copy 

1655 cached: Apply to index only, not working tree 

1656 check: Check only, don't apply 

1657 config: Repository configuration 

1658 

1659 Returns: 

1660 A tuple of (``original_lines``, ``old_mode``, ``should_continue``) where: 

1661 - ``original_lines``: Content lines if hunks need to be applied, None otherwise 

1662 - ``old_mode``: Mode of the source 

1663 - ``should_continue``: True to skip to next patch, False to continue processing 

1664 """ 

1665 from .index import IndexEntry, index_entry_from_stat 

1666 

1667 # Strip path components 

1668 src_stripped = src_path 

1669 dst_stripped = dst_path 

1670 if strip > 0: 

1671 src_parts = src_path.split(b"/") 

1672 if len(src_parts) > strip: 

1673 src_stripped = b"/".join(src_parts[strip:]) 

1674 dst_parts = dst_path.split(b"/") 

1675 if len(dst_parts) > strip: 

1676 dst_stripped = b"/".join(dst_parts[strip:]) 

1677 

1678 repo_path_bytes = r.path.encode("utf-8") if isinstance(r.path, str) else r.path 

1679 src_fs_path = _validate_patch_target(r, repo_path_bytes, src_stripped) 

1680 dst_fs_path = _validate_patch_target(r, repo_path_bytes, dst_stripped) 

1681 if not cached: 

1682 _refuse_existing_target(dst_fs_path, dst_stripped) 

1683 

1684 # Read content from source file 

1685 op_name = "rename" if is_rename else "copy" 

1686 current = _load_patch_target(r, src_fs_path, src_stripped, patch.old_mode, config) 

1687 if current is None: 

1688 raise ValueError( 

1689 f"Cannot {op_name}: source {src_stripped.decode('utf-8', errors='replace')} not found" 

1690 ) 

1691 content, old_mode = current 

1692 

1693 # If the content changes too, return it for further processing 

1694 if patch.hunks or patch.binary: 

1695 return content.splitlines(keepends=True), old_mode, False 

1696 

1697 # No hunks - pure rename/copy 

1698 if check: 

1699 return None, old_mode, True 

1700 

1701 new_mode = patch.new_mode or old_mode 

1702 index = r.open_index(config=config) 

1703 blob = Blob.from_string(content) 

1704 r.object_store.add_object(blob) 

1705 

1706 if not cached: 

1707 st = _write_patch_target(dst_fs_path, content, new_mode, config) 

1708 entry = index_entry_from_stat(st, blob.id, mode=new_mode) 

1709 else: 

1710 entry = IndexEntry( 

1711 ctime=(0, 0), 

1712 mtime=(0, 0), 

1713 dev=0, 

1714 ino=0, 

1715 mode=new_mode, 

1716 uid=0, 

1717 gid=0, 

1718 size=len(content), 

1719 sha=blob.id, 

1720 flags=0, 

1721 ) 

1722 

1723 index[dst_stripped] = entry 

1724 

1725 # For renames, remove the old file 

1726 if is_rename: 

1727 if not cached and os.path.lexists(src_fs_path): 

1728 os.remove(src_fs_path) 

1729 if src_stripped in index: 

1730 del index[src_stripped] 

1731 

1732 index.write() 

1733 return None, old_mode, True 

1734 

1735 

1736def _apply_binary_patch(patch: FilePatch, original: bytes, reverse: bool) -> bytes: 

1737 """Compute the new content of a file from a ``GIT binary patch``. 

1738 

1739 Args: 

1740 patch: Binary FilePatch 

1741 original: Current content of the file 

1742 reverse: Apply the reverse data instead of the forward data 

1743 

1744 Returns: 

1745 The patched content 

1746 

1747 Raises: 

1748 ValueError: If the patch data is invalid or doesn't apply 

1749 """ 

1750 from .errors import ApplyDeltaError 

1751 from .pack import apply_delta 

1752 

1753 if reverse: 

1754 data, is_delta = patch.binary_old, patch.binary_old_delta 

1755 else: 

1756 data, is_delta = patch.binary_new, patch.binary_new_delta 

1757 if data is None: 

1758 # "Binary files differ" message without actual patch data 

1759 raise NotImplementedError( 

1760 "Binary patch detected but no patch data provided (use git diff --binary)" 

1761 ) 

1762 try: 

1763 decoded = zlib.decompress(git_base85_decode(data)) 

1764 except (ValueError, zlib.error) as e: 

1765 raise ValueError(f"Failed to decode binary patch: {e}") 

1766 if not is_delta: 

1767 return decoded 

1768 try: 

1769 return b"".join(apply_delta(original, decoded)) 

1770 except ApplyDeltaError as e: 

1771 raise ValueError(f"Binary delta does not apply: {e}") 

1772 

1773 

1774def apply_patches( 

1775 r: "Repo", 

1776 patches: list[FilePatch], 

1777 cached: bool = False, 

1778 reverse: bool = False, 

1779 check: bool = False, 

1780 strip: int = 1, 

1781 three_way: bool = False, 

1782 *, 

1783 config: "Config | None" = None, 

1784) -> None: 

1785 """Apply a list of file patches to a repository. 

1786 

1787 Args: 

1788 r: Repository object 

1789 patches: List of FilePatch objects to apply 

1790 cached: Apply patch to index only, not working tree 

1791 reverse: Apply patch in reverse 

1792 check: Only check if patch can be applied, don't apply 

1793 strip: Number of leading path components to strip (default: 1) 

1794 three_way: Fall back to 3-way merge if patch does not apply cleanly 

1795 config: Repository configuration. If None, falls back to 

1796 ``r.get_config_stack()``. 

1797 

1798 Raises: 

1799 ValueError: If patch cannot be applied 

1800 """ 

1801 from .index import ( 

1802 IndexEntry, 

1803 index_entry_from_stat, 

1804 ) 

1805 

1806 if config is None: 

1807 config = r.get_config_stack() 

1808 

1809 for patch in patches: 

1810 # Determine the file path 

1811 # For renames/copies without hunks, old_path/new_path may be None 

1812 # Use local variables to avoid mutating the patch object 

1813 old_path = patch.old_path 

1814 new_path = patch.new_path 

1815 

1816 if new_path is None and old_path is None: 

1817 if patch.rename_to is not None: 

1818 # Use rename_to for the target path 

1819 new_path = patch.rename_to 

1820 old_path = patch.rename_from 

1821 elif patch.copy_to is not None: 

1822 # Use copy_to for the target path 

1823 new_path = patch.copy_to 

1824 old_path = patch.copy_from 

1825 else: 

1826 raise ValueError("Patch has no file path") 

1827 

1828 # Choose path based on operation 

1829 file_path: bytes 

1830 if new_path is None: 

1831 # Deletion 

1832 if old_path is None: 

1833 raise ValueError("Patch has no file path") 

1834 file_path = old_path 

1835 elif old_path is None: 

1836 # Addition 

1837 file_path = new_path 

1838 else: 

1839 # Modification (use new path) 

1840 file_path = new_path 

1841 

1842 # Strip path components 

1843 if strip > 0: 

1844 parts = file_path.split(b"/") 

1845 if len(parts) > strip: 

1846 file_path = b"/".join(parts[strip:]) 

1847 

1848 # Convert to filesystem path 

1849 tree_path = file_path 

1850 repo_path_bytes = r.path.encode("utf-8") if isinstance(r.path, str) else r.path 

1851 fs_path = _validate_patch_target(r, repo_path_bytes, file_path) 

1852 

1853 # Handle renames and copies 

1854 original_lines: list[bytes] | None = None 

1855 old_mode: int | None = None 

1856 if patch.rename_from is not None and patch.rename_to is not None: 

1857 original_lines, old_mode, should_continue = _apply_rename_or_copy( 

1858 r, 

1859 patch.rename_from, 

1860 patch.rename_to, 

1861 strip, 

1862 patch, 

1863 is_rename=True, 

1864 cached=cached, 

1865 check=check, 

1866 config=config, 

1867 ) 

1868 if should_continue: 

1869 continue 

1870 elif patch.copy_from is not None and patch.copy_to is not None: 

1871 original_lines, old_mode, should_continue = _apply_rename_or_copy( 

1872 r, 

1873 patch.copy_from, 

1874 patch.copy_to, 

1875 strip, 

1876 patch, 

1877 is_rename=False, 

1878 cached=cached, 

1879 check=check, 

1880 config=config, 

1881 ) 

1882 if should_continue: 

1883 continue 

1884 

1885 # Read original file content (unless already loaded from rename/copy) 

1886 if original_lines is None: 

1887 if old_path is None: 

1888 # New file 

1889 if not cached: 

1890 _refuse_existing_target(fs_path, tree_path) 

1891 original_lines = [] 

1892 else: 

1893 current = _load_patch_target( 

1894 r, fs_path, tree_path, patch.old_mode, config 

1895 ) 

1896 if current is None: 

1897 original_lines = [] 

1898 else: 

1899 content, old_mode = current 

1900 original_lines = content.splitlines(keepends=True) 

1901 new_mode = patch.new_mode or old_mode or patch.old_mode or 0o100644 

1902 

1903 # Reverse patch if requested 

1904 if reverse: 

1905 # Swap old and new in hunks 

1906 for hunk in patch.hunks: 

1907 hunk.old_start, hunk.new_start = hunk.new_start, hunk.old_start 

1908 hunk.old_count, hunk.new_count = hunk.new_count, hunk.old_count 

1909 # Swap +/- prefixes 

1910 reversed_lines = [] 

1911 for line in hunk.lines: 

1912 if line.startswith(b"+"): 

1913 reversed_lines.append(b"-" + line[1:]) 

1914 elif line.startswith(b"-"): 

1915 reversed_lines.append(b"+" + line[1:]) 

1916 else: 

1917 reversed_lines.append(line) 

1918 hunk.lines = reversed_lines 

1919 

1920 # Apply the patch 

1921 assert original_lines is not None 

1922 result: list[bytes] | None 

1923 # Deletions don't need the binary data, so treat those like text. 

1924 if patch.binary and new_path is not None: 

1925 result = _apply_binary_patch( 

1926 patch, b"".join(original_lines), reverse 

1927 ).splitlines(keepends=True) 

1928 else: 

1929 result = apply_patch_hunks(patch, original_lines) 

1930 

1931 if result is None and three_way: 

1932 # Try 3-way merge fallback 

1933 from .merge import merge_blobs 

1934 

1935 # Reconstruct base version from the patch 

1936 # Base is what you get by taking only the old lines from hunks 

1937 base_lines = [] 

1938 theirs_lines = [] 

1939 

1940 for hunk in patch.hunks: 

1941 for line in hunk.lines: 

1942 if line.startswith(b"\\"): 

1943 # Skip "\ No newline at end of file" markers 

1944 continue 

1945 elif line.startswith(b" "): 

1946 # Context line - in both base and theirs 

1947 content = line[1:] 

1948 if not content.endswith(b"\n"): 

1949 content += b"\n" 

1950 base_lines.append(content) 

1951 theirs_lines.append(content) 

1952 elif line.startswith(b"-"): 

1953 # Deletion - only in base 

1954 content = line[1:] 

1955 if not content.endswith(b"\n"): 

1956 content += b"\n" 

1957 base_lines.append(content) 

1958 elif line.startswith(b"+"): 

1959 # Addition - only in theirs 

1960 content = line[1:] 

1961 if not content.endswith(b"\n"): 

1962 content += b"\n" 

1963 theirs_lines.append(content) 

1964 

1965 # Create blobs for merging 

1966 base_content = b"".join(base_lines) 

1967 ours_content = b"".join(original_lines) 

1968 theirs_content = b"".join(theirs_lines) 

1969 

1970 base_blob = Blob.from_string(base_content) if base_content else None 

1971 ours_blob = Blob.from_string(ours_content) if ours_content else None 

1972 theirs_blob = Blob.from_string(theirs_content) 

1973 

1974 # Perform 3-way merge 

1975 merged_content, _had_conflicts = merge_blobs( 

1976 base_blob, ours_blob, theirs_blob, path=tree_path 

1977 ) 

1978 

1979 result = merged_content.splitlines(keepends=True) 

1980 

1981 # Note: if _had_conflicts is True, the result contains conflict markers 

1982 # Git would exit with error code, but we continue processing 

1983 elif result is None: 

1984 raise PatchApplicationFailure( 

1985 f"Patch does not apply to {file_path.decode('utf-8', errors='replace')}" 

1986 ) 

1987 

1988 if check: 

1989 # Just checking, don't actually apply 

1990 continue 

1991 

1992 # Write result 

1993 result_content = b"".join(result) 

1994 

1995 if new_path is None: 

1996 # File deletion 

1997 if not cached and os.path.lexists(fs_path): 

1998 os.remove(fs_path) 

1999 # Remove from index 

2000 index = r.open_index(config=config) 

2001 if tree_path in index: 

2002 del index[tree_path] 

2003 index.write() 

2004 else: 

2005 # File addition or modification 

2006 index = r.open_index(config=config) 

2007 blob = Blob.from_string(result_content) 

2008 r.object_store.add_object(blob) 

2009 

2010 if not cached: 

2011 st = _write_patch_target(fs_path, result_content, new_mode, config) 

2012 entry = index_entry_from_stat(st, blob.id, mode=new_mode) 

2013 else: 

2014 # Create a minimal index entry for cached-only changes 

2015 entry = IndexEntry( 

2016 ctime=(0, 0), 

2017 mtime=(0, 0), 

2018 dev=0, 

2019 ino=0, 

2020 mode=new_mode, 

2021 uid=0, 

2022 gid=0, 

2023 size=len(result_content), 

2024 sha=blob.id, 

2025 flags=0, 

2026 ) 

2027 

2028 index[tree_path] = entry 

2029 

2030 # Handle cleanup for renames with hunks 

2031 if patch.rename_from is not None and patch.rename_to is not None: 

2032 # Remove old file after successful rename 

2033 old_rename_path = patch.rename_from 

2034 if strip > 0: 

2035 old_parts = old_rename_path.split(b"/") 

2036 if len(old_parts) > strip: 

2037 old_rename_path = b"/".join(old_parts[strip:]) 

2038 

2039 old_fs_path = os.path.join( 

2040 r.path.encode("utf-8") if isinstance(r.path, str) else r.path, 

2041 old_rename_path, 

2042 ) 

2043 

2044 if not cached and os.path.lexists(old_fs_path): 

2045 os.remove(old_fs_path) 

2046 if old_rename_path in index: 

2047 del index[old_rename_path] 

2048 

2049 index.write() 

2050 

2051 

2052def mailinfo( 

2053 msg: email.message.Message | BinaryIO | TextIO, 

2054 keep_subject: bool = False, 

2055 keep_non_patch: bool = False, 

2056 encoding: str | None = None, 

2057 scissors: bool = False, 

2058 message_id: bool = False, 

2059) -> MailinfoResult: 

2060 """Extract patch information from an email message. 

2061 

2062 This function parses an email message and extracts commit metadata 

2063 (author, email, subject) and separates the commit message from the 

2064 patch content, similar to git mailinfo. 

2065 

2066 Args: 

2067 msg: Email message (email.message.Message object) or file handle to read from 

2068 keep_subject: If True, keep subject intact without munging (-k) 

2069 keep_non_patch: If True, only strip [PATCH] from brackets (-b) 

2070 encoding: Character encoding to use (default: detect from message) 

2071 scissors: If True, remove everything before scissors line 

2072 message_id: If True, include Message-ID in commit message (-m) 

2073 

2074 Returns: 

2075 MailinfoResult with parsed information 

2076 

2077 Raises: 

2078 ValueError: If message is malformed or missing required fields 

2079 """ 

2080 # Parse message if given a file handle 

2081 parsed_msg: email.message.Message 

2082 if not isinstance(msg, email.message.Message): 

2083 if hasattr(msg, "read"): 

2084 content = msg.read() 

2085 if isinstance(content, bytes): 

2086 bparser = email.parser.BytesParser() 

2087 parsed_msg = bparser.parsebytes(content) 

2088 else: 

2089 sparser = email.parser.Parser() 

2090 parsed_msg = sparser.parsestr(content) 

2091 else: 

2092 raise ValueError("msg must be an email.message.Message or file-like object") 

2093 else: 

2094 parsed_msg = msg 

2095 

2096 # Detect encoding from message if not specified 

2097 if encoding is None: 

2098 encoding = parsed_msg.get_content_charset() or "utf-8" 

2099 

2100 # Extract author information 

2101 from_header = parsed_msg.get("From", "") 

2102 if not from_header: 

2103 raise ValueError("Email message missing 'From' header") 

2104 

2105 # Parse "Name <email>" format 

2106 author_name, author_email = email.utils.parseaddr(from_header) 

2107 if not author_email: 

2108 raise ValueError( 

2109 f"Could not parse email address from 'From' header: {from_header}" 

2110 ) 

2111 

2112 # Extract date 

2113 date_header = parsed_msg.get("Date") 

2114 author_date = date_header if date_header else None 

2115 

2116 # Extract and process subject 

2117 subject = parsed_msg.get("Subject", "") 

2118 if not subject: 

2119 subject = "(no subject)" 

2120 

2121 # Convert Header object to string if needed 

2122 subject = str(subject) 

2123 

2124 # Remove newlines from subject 

2125 subject = subject.replace("\n", " ").replace("\r", " ") 

2126 subject = _munge_subject(subject, keep_subject, keep_non_patch) 

2127 

2128 # Extract Message-ID if requested 

2129 msg_id = None 

2130 if message_id: 

2131 msg_id = parsed_msg.get("Message-ID") 

2132 

2133 # Get message body 

2134 body = parsed_msg.get_payload(decode=True) 

2135 if body is None: 

2136 body = b"" 

2137 elif isinstance(body, str): 

2138 body = body.encode(encoding) 

2139 elif not isinstance(body, bytes): 

2140 # Handle multipart or other types 

2141 body = str(body).encode(encoding) 

2142 

2143 # Split into lines 

2144 lines = body.splitlines(keepends=True) 

2145 

2146 # Handle scissors 

2147 scissors_idx = None 

2148 if scissors: 

2149 scissors_idx = _find_scissors_line(lines) 

2150 if scissors_idx is not None: 

2151 # Remove everything up to and including scissors line 

2152 lines = lines[scissors_idx + 1 :] 

2153 

2154 # Separate commit message from patch 

2155 # Look for the "---" separator that indicates start of diffstat/patch 

2156 message_lines: list[bytes] = [] 

2157 patch_lines: list[bytes] = [] 

2158 in_patch = False 

2159 

2160 for line in lines: 

2161 if not in_patch and line == b"---\n": 

2162 in_patch = True 

2163 patch_lines.append(line) 

2164 elif in_patch: 

2165 # Stop at signature marker "-- " 

2166 if line == b"-- \n": 

2167 break 

2168 patch_lines.append(line) 

2169 else: 

2170 message_lines.append(line) 

2171 

2172 # Build commit message 

2173 commit_message = b"".join(message_lines).decode(encoding, errors="replace") 

2174 

2175 # Clean up commit message 

2176 commit_message = commit_message.strip() 

2177 

2178 # Append Message-ID if requested 

2179 if message_id and msg_id: 

2180 if commit_message: 

2181 commit_message += "\n\n" 

2182 commit_message += f"Message-ID: {msg_id}" 

2183 

2184 # Build patch content 

2185 patch_content = b"".join(patch_lines).decode(encoding, errors="replace") 

2186 

2187 return MailinfoResult( 

2188 author_name=author_name, 

2189 author_email=author_email, 

2190 author_date=author_date, 

2191 subject=subject, 

2192 message=commit_message, 

2193 patch=patch_content, 

2194 message_id=msg_id, 

2195 )