1# SPDX-License-Identifier: GPL-2.0+ 2# Copyright (c) 2011 The Chromium OS Authors. 3# 4 5"""Handles parsing a stream of commits/emails from 'git log' or other source""" 6 7import collections 8import datetime 9import io 10import math 11import os 12import re 13import queue 14import shutil 15import tempfile 16 17from patman import command 18from patman import commit 19from patman import gitutil 20from patman.series import Series 21 22# Tags that we detect and remove 23RE_REMOVE = re.compile(r'^BUG=|^TEST=|^BRANCH=|^Review URL:' 24 r'|Reviewed-on:|Commit-\w*:') 25 26# Lines which are allowed after a TEST= line 27RE_ALLOWED_AFTER_TEST = re.compile('^Signed-off-by:') 28 29# Signoffs 30RE_SIGNOFF = re.compile('^Signed-off-by: *(.*)') 31 32# Cover letter tag 33RE_COVER = re.compile('^Cover-([a-z-]*): *(.*)') 34 35# Patch series tag 36RE_SERIES_TAG = re.compile('^Series-([a-z-]*): *(.*)') 37 38# Change-Id will be used to generate the Message-Id and then be stripped 39RE_CHANGE_ID = re.compile('^Change-Id: *(.*)') 40 41# Commit series tag 42RE_COMMIT_TAG = re.compile('^Commit-([a-z-]*): *(.*)') 43 44# Commit tags that we want to collect and keep 45RE_TAG = re.compile('^(Tested-by|Acked-by|Reviewed-by|Patch-cc|Fixes): (.*)') 46 47# The start of a new commit in the git log 48RE_COMMIT = re.compile('^commit ([0-9a-f]*)$') 49 50# We detect these since checkpatch doesn't always do it 51RE_SPACE_BEFORE_TAB = re.compile('^[+].* \t') 52 53# Match indented lines for changes 54RE_LEADING_WHITESPACE = re.compile(r'^\s') 55 56# Detect a 'diff' line 57RE_DIFF = re.compile(r'^>.*diff --git a/(.*) b/(.*)$') 58 59# Detect a context line, like '> @@ -153,8 +153,13 @@ CheckPatch 60RE_LINE = re.compile(r'>.*@@ \-(\d+),\d+ \+(\d+),\d+ @@ *(.*)') 61 62# Detect line with invalid TAG 63RE_INV_TAG = re.compile('^Serie-([a-z-]*): *(.*)') 64 65# States we can be in - can we use range() and still have comments? 66STATE_MSG_HEADER = 0 # Still in the message header 67STATE_PATCH_SUBJECT = 1 # In patch subject (first line of log for a commit) 68STATE_PATCH_HEADER = 2 # In patch header (after the subject) 69STATE_DIFFS = 3 # In the diff part (past --- line) 70 71class PatchStream: 72 """Class for detecting/injecting tags in a patch or series of patches 73 74 We support processing the output of 'git log' to read out the tags we 75 are interested in. We can also process a patch file in order to remove 76 unwanted tags or inject additional ones. These correspond to the two 77 phases of processing. 78 """ 79 def __init__(self, series, is_log=False): 80 self.skip_blank = False # True to skip a single blank line 81 self.found_test = False # Found a TEST= line 82 self.lines_after_test = 0 # Number of lines found after TEST= 83 self.linenum = 1 # Output line number we are up to 84 self.in_section = None # Name of start...END section we are in 85 self.notes = [] # Series notes 86 self.section = [] # The current section...END section 87 self.series = series # Info about the patch series 88 self.is_log = is_log # True if indent like git log 89 self.in_change = None # Name of the change list we are in 90 self.change_version = 0 # Non-zero if we are in a change list 91 self.change_lines = [] # Lines of the current change 92 self.blank_count = 0 # Number of blank lines stored up 93 self.state = STATE_MSG_HEADER # What state are we in? 94 self.commit = None # Current commit 95 # List of unquoted test blocks, each a list of str lines 96 self.snippets = [] 97 self.cur_diff = None # Last 'diff' line seen (str) 98 self.cur_line = None # Last context (@@) line seen (str) 99 self.recent_diff = None # 'diff' line for current snippet (str) 100 self.recent_line = None # '@@' line for current snippet (str) 101 self.recent_quoted = collections.deque([], 5) 102 self.recent_unquoted = queue.Queue() 103 self.was_quoted = None 104 105 @staticmethod 106 def process_text(text, is_comment=False): 107 """Process some text through this class using a default Commit/Series 108 109 Args: 110 text (str): Text to parse 111 is_comment (bool): True if this is a comment rather than a patch. 112 If True, PatchStream doesn't expect a patch subject at the 113 start, but jumps straight into the body 114 115 Returns: 116 PatchStream: object with results 117 """ 118 pstrm = PatchStream(Series()) 119 pstrm.commit = commit.Commit(None) 120 infd = io.StringIO(text) 121 outfd = io.StringIO() 122 if is_comment: 123 pstrm.state = STATE_PATCH_HEADER 124 pstrm.process_stream(infd, outfd) 125 return pstrm 126 127 def _add_warn(self, warn): 128 """Add a new warning to report to the user about the current commit 129 130 The new warning is added to the current commit if not already present. 131 132 Args: 133 warn (str): Warning to report 134 135 Raises: 136 ValueError: Warning is generated with no commit associated 137 """ 138 if not self.commit: 139 print('Warning outside commit: %s' % warn) 140 elif warn not in self.commit.warn: 141 self.commit.warn.append(warn) 142 143 def _add_to_series(self, line, name, value): 144 """Add a new Series-xxx tag. 145 146 When a Series-xxx tag is detected, we come here to record it, if we 147 are scanning a 'git log'. 148 149 Args: 150 line (str): Source line containing tag (useful for debug/error 151 messages) 152 name (str): Tag name (part after 'Series-') 153 value (str): Tag value (part after 'Series-xxx: ') 154 """ 155 if name == 'notes': 156 self.in_section = name 157 self.skip_blank = False 158 if self.is_log: 159 warn = self.series.AddTag(self.commit, line, name, value) 160 if warn: 161 self.commit.warn.append(warn) 162 163 def _add_to_commit(self, name): 164 """Add a new Commit-xxx tag. 165 166 When a Commit-xxx tag is detected, we come here to record it. 167 168 Args: 169 name (str): Tag name (part after 'Commit-') 170 """ 171 if name == 'notes': 172 self.in_section = 'commit-' + name 173 self.skip_blank = False 174 175 def _add_commit_rtag(self, rtag_type, who): 176 """Add a response tag to the current commit 177 178 Args: 179 rtag_type (str): rtag type (e.g. 'Reviewed-by') 180 who (str): Person who gave that rtag, e.g. 181 'Fred Bloggs <fred@bloggs.org>' 182 """ 183 self.commit.AddRtag(rtag_type, who) 184 185 def _close_commit(self): 186 """Save the current commit into our commit list, and reset our state""" 187 if self.commit and self.is_log: 188 self.series.AddCommit(self.commit) 189 self.commit = None 190 # If 'END' is missing in a 'Cover-letter' section, and that section 191 # happens to show up at the very end of the commit message, this is 192 # the chance for us to fix it up. 193 if self.in_section == 'cover' and self.is_log: 194 self.series.cover = self.section 195 self.in_section = None 196 self.skip_blank = True 197 self.section = [] 198 199 self.cur_diff = None 200 self.recent_diff = None 201 self.recent_line = None 202 203 def _parse_version(self, value, line): 204 """Parse a version from a *-changes tag 205 206 Args: 207 value (str): Tag value (part after 'xxx-changes: ' 208 line (str): Source line containing tag 209 210 Returns: 211 int: The version as an integer 212 213 Raises: 214 ValueError: the value cannot be converted 215 """ 216 try: 217 return int(value) 218 except ValueError: 219 raise ValueError("%s: Cannot decode version info '%s'" % 220 (self.commit.hash, line)) 221 222 def _finalise_change(self): 223 """_finalise a (multi-line) change and add it to the series or commit""" 224 if not self.change_lines: 225 return 226 change = '\n'.join(self.change_lines) 227 228 if self.in_change == 'Series': 229 self.series.AddChange(self.change_version, self.commit, change) 230 elif self.in_change == 'Cover': 231 self.series.AddChange(self.change_version, None, change) 232 elif self.in_change == 'Commit': 233 self.commit.AddChange(self.change_version, change) 234 self.change_lines = [] 235 236 def _finalise_snippet(self): 237 """Finish off a snippet and add it to the list 238 239 This is called when we get to the end of a snippet, i.e. the we enter 240 the next block of quoted text: 241 242 This is a comment from someone. 243 244 Something else 245 246 > Now we have some code <----- end of snippet 247 > more code 248 249 Now a comment about the above code 250 251 This adds the snippet to our list 252 """ 253 quoted_lines = [] 254 while self.recent_quoted: 255 quoted_lines.append(self.recent_quoted.popleft()) 256 unquoted_lines = [] 257 valid = False 258 while not self.recent_unquoted.empty(): 259 text = self.recent_unquoted.get() 260 if not (text.startswith('On ') and text.endswith('wrote:')): 261 unquoted_lines.append(text) 262 if text: 263 valid = True 264 if valid: 265 lines = [] 266 if self.recent_diff: 267 lines.append('> File: %s' % self.recent_diff) 268 if self.recent_line: 269 out = '> Line: %s / %s' % self.recent_line[:2] 270 if self.recent_line[2]: 271 out += ': %s' % self.recent_line[2] 272 lines.append(out) 273 lines += quoted_lines + unquoted_lines 274 if lines: 275 self.snippets.append(lines) 276 277 def process_line(self, line): 278 """Process a single line of a patch file or commit log 279 280 This process a line and returns a list of lines to output. The list 281 may be empty or may contain multiple output lines. 282 283 This is where all the complicated logic is located. The class's 284 state is used to move between different states and detect things 285 properly. 286 287 We can be in one of two modes: 288 self.is_log == True: This is 'git log' mode, where most output is 289 indented by 4 characters and we are scanning for tags 290 291 self.is_log == False: This is 'patch' mode, where we already have 292 all the tags, and are processing patches to remove junk we 293 don't want, and add things we think are required. 294 295 Args: 296 line (str): text line to process 297 298 Returns: 299 list: list of output lines, or [] if nothing should be output 300 301 Raises: 302 ValueError: a fatal error occurred while parsing, e.g. an END 303 without a starting tag, or two commits with two change IDs 304 """ 305 # Initially we have no output. Prepare the input line string 306 out = [] 307 line = line.rstrip('\n') 308 309 commit_match = RE_COMMIT.match(line) if self.is_log else None 310 311 if self.is_log: 312 if line[:4] == ' ': 313 line = line[4:] 314 315 # Handle state transition and skipping blank lines 316 series_tag_match = RE_SERIES_TAG.match(line) 317 change_id_match = RE_CHANGE_ID.match(line) 318 commit_tag_match = RE_COMMIT_TAG.match(line) 319 cover_match = RE_COVER.match(line) 320 signoff_match = RE_SIGNOFF.match(line) 321 leading_whitespace_match = RE_LEADING_WHITESPACE.match(line) 322 diff_match = RE_DIFF.match(line) 323 line_match = RE_LINE.match(line) 324 invalid_match = RE_INV_TAG.match(line) 325 tag_match = None 326 if self.state == STATE_PATCH_HEADER: 327 tag_match = RE_TAG.match(line) 328 is_blank = not line.strip() 329 if is_blank: 330 if (self.state == STATE_MSG_HEADER 331 or self.state == STATE_PATCH_SUBJECT): 332 self.state += 1 333 334 # We don't have a subject in the text stream of patch files 335 # It has its own line with a Subject: tag 336 if not self.is_log and self.state == STATE_PATCH_SUBJECT: 337 self.state += 1 338 elif commit_match: 339 self.state = STATE_MSG_HEADER 340 341 # If a tag is detected, or a new commit starts 342 if series_tag_match or commit_tag_match or change_id_match or \ 343 cover_match or signoff_match or self.state == STATE_MSG_HEADER: 344 # but we are already in a section, this means 'END' is missing 345 # for that section, fix it up. 346 if self.in_section: 347 self._add_warn("Missing 'END' in section '%s'" % self.in_section) 348 if self.in_section == 'cover': 349 self.series.cover = self.section 350 elif self.in_section == 'notes': 351 if self.is_log: 352 self.series.notes += self.section 353 elif self.in_section == 'commit-notes': 354 if self.is_log: 355 self.commit.notes += self.section 356 else: 357 # This should not happen 358 raise ValueError("Unknown section '%s'" % self.in_section) 359 self.in_section = None 360 self.skip_blank = True 361 self.section = [] 362 # but we are already in a change list, that means a blank line 363 # is missing, fix it up. 364 if self.in_change: 365 self._add_warn("Missing 'blank line' in section '%s-changes'" % 366 self.in_change) 367 self._finalise_change() 368 self.in_change = None 369 self.change_version = 0 370 371 # If we are in a section, keep collecting lines until we see END 372 if self.in_section: 373 if line == 'END': 374 if self.in_section == 'cover': 375 self.series.cover = self.section 376 elif self.in_section == 'notes': 377 if self.is_log: 378 self.series.notes += self.section 379 elif self.in_section == 'commit-notes': 380 if self.is_log: 381 self.commit.notes += self.section 382 else: 383 # This should not happen 384 raise ValueError("Unknown section '%s'" % self.in_section) 385 self.in_section = None 386 self.skip_blank = True 387 self.section = [] 388 else: 389 self.section.append(line) 390 391 # If we are not in a section, it is an unexpected END 392 elif line == 'END': 393 raise ValueError("'END' wihout section") 394 395 # Detect the commit subject 396 elif not is_blank and self.state == STATE_PATCH_SUBJECT: 397 self.commit.subject = line 398 399 # Detect the tags we want to remove, and skip blank lines 400 elif RE_REMOVE.match(line) and not commit_tag_match: 401 self.skip_blank = True 402 403 # TEST= should be the last thing in the commit, so remove 404 # everything after it 405 if line.startswith('TEST='): 406 self.found_test = True 407 elif self.skip_blank and is_blank: 408 self.skip_blank = False 409 410 # Detect Cover-xxx tags 411 elif cover_match: 412 name = cover_match.group(1) 413 value = cover_match.group(2) 414 if name == 'letter': 415 self.in_section = 'cover' 416 self.skip_blank = False 417 elif name == 'letter-cc': 418 self._add_to_series(line, 'cover-cc', value) 419 elif name == 'changes': 420 self.in_change = 'Cover' 421 self.change_version = self._parse_version(value, line) 422 423 # If we are in a change list, key collected lines until a blank one 424 elif self.in_change: 425 if is_blank: 426 # Blank line ends this change list 427 self._finalise_change() 428 self.in_change = None 429 self.change_version = 0 430 elif line == '---': 431 self._finalise_change() 432 self.in_change = None 433 self.change_version = 0 434 out = self.process_line(line) 435 elif self.is_log: 436 if not leading_whitespace_match: 437 self._finalise_change() 438 self.change_lines.append(line) 439 self.skip_blank = False 440 441 # Detect Series-xxx tags 442 elif series_tag_match: 443 name = series_tag_match.group(1) 444 value = series_tag_match.group(2) 445 if name == 'changes': 446 # value is the version number: e.g. 1, or 2 447 self.in_change = 'Series' 448 self.change_version = self._parse_version(value, line) 449 else: 450 self._add_to_series(line, name, value) 451 self.skip_blank = True 452 453 # Detect Change-Id tags 454 elif change_id_match: 455 value = change_id_match.group(1) 456 if self.is_log: 457 if self.commit.change_id: 458 raise ValueError( 459 "%s: Two Change-Ids: '%s' vs. '%s'" % 460 (self.commit.hash, self.commit.change_id, value)) 461 self.commit.change_id = value 462 self.skip_blank = True 463 464 # Detect Commit-xxx tags 465 elif commit_tag_match: 466 name = commit_tag_match.group(1) 467 value = commit_tag_match.group(2) 468 if name == 'notes': 469 self._add_to_commit(name) 470 self.skip_blank = True 471 elif name == 'changes': 472 self.in_change = 'Commit' 473 self.change_version = self._parse_version(value, line) 474 else: 475 self._add_warn('Line %d: Ignoring Commit-%s' % 476 (self.linenum, name)) 477 478 # Detect invalid tags 479 elif invalid_match: 480 raise ValueError("Line %d: Invalid tag = '%s'" % 481 (self.linenum, line)) 482 483 # Detect the start of a new commit 484 elif commit_match: 485 self._close_commit() 486 self.commit = commit.Commit(commit_match.group(1)) 487 488 # Detect tags in the commit message 489 elif tag_match: 490 rtag_type, who = tag_match.groups() 491 self._add_commit_rtag(rtag_type, who) 492 # Remove Tested-by self, since few will take much notice 493 if (rtag_type == 'Tested-by' and 494 who.find(os.getenv('USER') + '@') != -1): 495 self._add_warn("Ignoring '%s'" % line) 496 elif rtag_type == 'Patch-cc': 497 self.commit.AddCc(who.split(',')) 498 else: 499 out = [line] 500 501 # Suppress duplicate signoffs 502 elif signoff_match: 503 if (self.is_log or not self.commit or 504 self.commit.CheckDuplicateSignoff(signoff_match.group(1))): 505 out = [line] 506 507 # Well that means this is an ordinary line 508 else: 509 # Look for space before tab 510 mat = RE_SPACE_BEFORE_TAB.match(line) 511 if mat: 512 self._add_warn('Line %d/%d has space before tab' % 513 (self.linenum, mat.start())) 514 515 # OK, we have a valid non-blank line 516 out = [line] 517 self.linenum += 1 518 self.skip_blank = False 519 520 if diff_match: 521 self.cur_diff = diff_match.group(1) 522 523 # If this is quoted, keep recent lines 524 if not diff_match and self.linenum > 1 and line: 525 if line.startswith('>'): 526 if not self.was_quoted: 527 self._finalise_snippet() 528 self.recent_line = None 529 if not line_match: 530 self.recent_quoted.append(line) 531 self.was_quoted = True 532 self.recent_diff = self.cur_diff 533 else: 534 self.recent_unquoted.put(line) 535 self.was_quoted = False 536 537 if line_match: 538 self.recent_line = line_match.groups() 539 540 if self.state == STATE_DIFFS: 541 pass 542 543 # If this is the start of the diffs section, emit our tags and 544 # change log 545 elif line == '---': 546 self.state = STATE_DIFFS 547 548 # Output the tags (signoff first), then change list 549 out = [] 550 log = self.series.MakeChangeLog(self.commit) 551 out += [line] 552 if self.commit: 553 out += self.commit.notes 554 out += [''] + log 555 elif self.found_test: 556 if not RE_ALLOWED_AFTER_TEST.match(line): 557 self.lines_after_test += 1 558 559 return out 560 561 def finalise(self): 562 """Close out processing of this patch stream""" 563 self._finalise_snippet() 564 self._finalise_change() 565 self._close_commit() 566 if self.lines_after_test: 567 self._add_warn('Found %d lines after TEST=' % self.lines_after_test) 568 569 def _write_message_id(self, outfd): 570 """Write the Message-Id into the output. 571 572 This is based on the Change-Id in the original patch, the version, 573 and the prefix. 574 575 Args: 576 outfd (io.IOBase): Output stream file object 577 """ 578 if not self.commit.change_id: 579 return 580 581 # If the count is -1 we're testing, so use a fixed time 582 if self.commit.count == -1: 583 time_now = datetime.datetime(1999, 12, 31, 23, 59, 59) 584 else: 585 time_now = datetime.datetime.now() 586 587 # In theory there is email.utils.make_msgid() which would be nice 588 # to use, but it already produces something way too long and thus 589 # will produce ugly commit lines if someone throws this into 590 # a "Link:" tag in the final commit. So (sigh) roll our own. 591 592 # Start with the time; presumably we wouldn't send the same series 593 # with the same Change-Id at the exact same second. 594 parts = [time_now.strftime("%Y%m%d%H%M%S")] 595 596 # These seem like they would be nice to include. 597 if 'prefix' in self.series: 598 parts.append(self.series['prefix']) 599 if 'postfix' in self.series: 600 parts.append(self.serties['postfix']) 601 if 'version' in self.series: 602 parts.append("v%s" % self.series['version']) 603 604 parts.append(str(self.commit.count + 1)) 605 606 # The Change-Id must be last, right before the @ 607 parts.append(self.commit.change_id) 608 609 # Join parts together with "." and write it out. 610 outfd.write('Message-Id: <%s@changeid>\n' % '.'.join(parts)) 611 612 def process_stream(self, infd, outfd): 613 """Copy a stream from infd to outfd, filtering out unwanting things. 614 615 This is used to process patch files one at a time. 616 617 Args: 618 infd (io.IOBase): Input stream file object 619 outfd (io.IOBase): Output stream file object 620 """ 621 # Extract the filename from each diff, for nice warnings 622 fname = None 623 last_fname = None 624 re_fname = re.compile('diff --git a/(.*) b/.*') 625 626 self._write_message_id(outfd) 627 628 while True: 629 line = infd.readline() 630 if not line: 631 break 632 out = self.process_line(line) 633 634 # Try to detect blank lines at EOF 635 for line in out: 636 match = re_fname.match(line) 637 if match: 638 last_fname = fname 639 fname = match.group(1) 640 if line == '+': 641 self.blank_count += 1 642 else: 643 if self.blank_count and (line == '-- ' or match): 644 self._add_warn("Found possible blank line(s) at end of file '%s'" % 645 last_fname) 646 outfd.write('+\n' * self.blank_count) 647 outfd.write(line + '\n') 648 self.blank_count = 0 649 self.finalise() 650 651def insert_tags(msg, tags_to_emit): 652 """Add extra tags to a commit message 653 654 The tags are added after an existing block of tags if found, otherwise at 655 the end. 656 657 Args: 658 msg (str): Commit message 659 tags_to_emit (list): List of tags to emit, each a str 660 661 Returns: 662 (str) new message 663 """ 664 out = [] 665 done = False 666 emit_tags = False 667 emit_blank = False 668 for line in msg.splitlines(): 669 if not done: 670 signoff_match = RE_SIGNOFF.match(line) 671 tag_match = RE_TAG.match(line) 672 if tag_match or signoff_match: 673 emit_tags = True 674 if emit_tags and not tag_match and not signoff_match: 675 out += tags_to_emit 676 emit_tags = False 677 done = True 678 emit_blank = not (signoff_match or tag_match) 679 else: 680 emit_blank = line 681 out.append(line) 682 if not done: 683 if emit_blank: 684 out.append('') 685 out += tags_to_emit 686 return '\n'.join(out) 687 688def get_list(commit_range, git_dir=None, count=None): 689 """Get a log of a list of comments 690 691 This returns the output of 'git log' for the selected commits 692 693 Args: 694 commit_range (str): Range of commits to count (e.g. 'HEAD..base') 695 git_dir (str): Path to git repositiory (None to use default) 696 count (int): Number of commits to list, or None for no limit 697 698 Returns 699 str: String containing the contents of the git log 700 """ 701 params = gitutil.LogCmd(commit_range, reverse=True, count=count, 702 git_dir=git_dir) 703 return command.RunPipe([params], capture=True).stdout 704 705def get_metadata_for_list(commit_range, git_dir=None, count=None, 706 series=None, allow_overwrite=False): 707 """Reads out patch series metadata from the commits 708 709 This does a 'git log' on the relevant commits and pulls out the tags we 710 are interested in. 711 712 Args: 713 commit_range (str): Range of commits to count (e.g. 'HEAD..base') 714 git_dir (str): Path to git repositiory (None to use default) 715 count (int): Number of commits to list, or None for no limit 716 series (Series): Object to add information into. By default a new series 717 is started. 718 allow_overwrite (bool): Allow tags to overwrite an existing tag 719 720 Returns: 721 Series: Object containing information about the commits. 722 """ 723 if not series: 724 series = Series() 725 series.allow_overwrite = allow_overwrite 726 stdout = get_list(commit_range, git_dir, count) 727 pst = PatchStream(series, is_log=True) 728 for line in stdout.splitlines(): 729 pst.process_line(line) 730 pst.finalise() 731 return series 732 733def get_metadata(branch, start, count): 734 """Reads out patch series metadata from the commits 735 736 This does a 'git log' on the relevant commits and pulls out the tags we 737 are interested in. 738 739 Args: 740 branch (str): Branch to use (None for current branch) 741 start (int): Commit to start from: 0=branch HEAD, 1=next one, etc. 742 count (int): Number of commits to list 743 744 Returns: 745 Series: Object containing information about the commits. 746 """ 747 return get_metadata_for_list( 748 '%s~%d' % (branch if branch else 'HEAD', start), None, count) 749 750def get_metadata_for_test(text): 751 """Process metadata from a file containing a git log. Used for tests 752 753 Args: 754 text: 755 756 Returns: 757 Series: Object containing information about the commits. 758 """ 759 series = Series() 760 pst = PatchStream(series, is_log=True) 761 for line in text.splitlines(): 762 pst.process_line(line) 763 pst.finalise() 764 return series 765 766def fix_patch(backup_dir, fname, series, cmt): 767 """Fix up a patch file, by adding/removing as required. 768 769 We remove our tags from the patch file, insert changes lists, etc. 770 The patch file is processed in place, and overwritten. 771 772 A backup file is put into backup_dir (if not None). 773 774 Args: 775 backup_dir (str): Path to directory to use to backup the file 776 fname (str): Filename to patch file to process 777 series (Series): Series information about this patch set 778 cmt (Commit): Commit object for this patch file 779 780 Return: 781 list: A list of errors, each str, or [] if all ok. 782 """ 783 handle, tmpname = tempfile.mkstemp() 784 outfd = os.fdopen(handle, 'w', encoding='utf-8') 785 infd = open(fname, 'r', encoding='utf-8') 786 pst = PatchStream(series) 787 pst.commit = cmt 788 pst.process_stream(infd, outfd) 789 infd.close() 790 outfd.close() 791 792 # Create a backup file if required 793 if backup_dir: 794 shutil.copy(fname, os.path.join(backup_dir, os.path.basename(fname))) 795 shutil.move(tmpname, fname) 796 return cmt.warn 797 798def fix_patches(series, fnames): 799 """Fix up a list of patches identified by filenames 800 801 The patch files are processed in place, and overwritten. 802 803 Args: 804 series (Series): The Series object 805 fnames (:type: list of str): List of patch files to process 806 """ 807 # Current workflow creates patches, so we shouldn't need a backup 808 backup_dir = None #tempfile.mkdtemp('clean-patch') 809 count = 0 810 for fname in fnames: 811 cmt = series.commits[count] 812 cmt.patch = fname 813 cmt.count = count 814 result = fix_patch(backup_dir, fname, series, cmt) 815 if result: 816 print('%d warning%s for %s:' % 817 (len(result), 's' if len(result) > 1 else '', fname)) 818 for warn in result: 819 print('\t%s' % warn) 820 print() 821 count += 1 822 print('Cleaned %d patch%s' % (count, 'es' if count > 1 else '')) 823 824def insert_cover_letter(fname, series, count): 825 """Inserts a cover letter with the required info into patch 0 826 827 Args: 828 fname (str): Input / output filename of the cover letter file 829 series (Series): Series object 830 count (int): Number of patches in the series 831 """ 832 fil = open(fname, 'r') 833 lines = fil.readlines() 834 fil.close() 835 836 fil = open(fname, 'w') 837 text = series.cover 838 prefix = series.GetPatchPrefix() 839 for line in lines: 840 if line.startswith('Subject:'): 841 # if more than 10 or 100 patches, it should say 00/xx, 000/xxx, etc 842 zero_repeat = int(math.log10(count)) + 1 843 zero = '0' * zero_repeat 844 line = 'Subject: [%s %s/%d] %s\n' % (prefix, zero, count, text[0]) 845 846 # Insert our cover letter 847 elif line.startswith('*** BLURB HERE ***'): 848 # First the blurb test 849 line = '\n'.join(text[1:]) + '\n' 850 if series.get('notes'): 851 line += '\n'.join(series.notes) + '\n' 852 853 # Now the change list 854 out = series.MakeChangeLog(None) 855 line += '\n' + '\n'.join(out) 856 fil.write(line) 857 fil.close() 858