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