1# SPDX-License-Identifier: GPL-2.0+ 2# Copyright (c) 2011 The Chromium OS Authors. 3# 4 5import datetime 6import math 7import os 8import re 9import shutil 10import tempfile 11 12import command 13import commit 14import gitutil 15from series import Series 16 17# Tags that we detect and remove 18re_remove = re.compile('^BUG=|^TEST=|^BRANCH=|^Review URL:' 19 '|Reviewed-on:|Commit-\w*:') 20 21# Lines which are allowed after a TEST= line 22re_allowed_after_test = re.compile('^Signed-off-by:') 23 24# Signoffs 25re_signoff = re.compile('^Signed-off-by: *(.*)') 26 27# The start of the cover letter 28re_cover = re.compile('^Cover-letter:') 29 30# A cover letter Cc 31re_cover_cc = re.compile('^Cover-letter-cc: *(.*)') 32 33# Patch series tag 34re_series_tag = re.compile('^Series-([a-z-]*): *(.*)') 35 36# Change-Id will be used to generate the Message-Id and then be stripped 37re_change_id = re.compile('^Change-Id: *(.*)') 38 39# Commit series tag 40re_commit_tag = re.compile('^Commit-([a-z-]*): *(.*)') 41 42# Commit tags that we want to collect and keep 43re_tag = re.compile('^(Tested-by|Acked-by|Reviewed-by|Patch-cc): (.*)') 44 45# The start of a new commit in the git log 46re_commit = re.compile('^commit ([0-9a-f]*)$') 47 48# We detect these since checkpatch doesn't always do it 49re_space_before_tab = re.compile('^[+].* \t') 50 51# States we can be in - can we use range() and still have comments? 52STATE_MSG_HEADER = 0 # Still in the message header 53STATE_PATCH_SUBJECT = 1 # In patch subject (first line of log for a commit) 54STATE_PATCH_HEADER = 2 # In patch header (after the subject) 55STATE_DIFFS = 3 # In the diff part (past --- line) 56 57class PatchStream: 58 """Class for detecting/injecting tags in a patch or series of patches 59 60 We support processing the output of 'git log' to read out the tags we 61 are interested in. We can also process a patch file in order to remove 62 unwanted tags or inject additional ones. These correspond to the two 63 phases of processing. 64 """ 65 def __init__(self, series, name=None, is_log=False): 66 self.skip_blank = False # True to skip a single blank line 67 self.found_test = False # Found a TEST= line 68 self.lines_after_test = 0 # MNumber of lines found after TEST= 69 self.warn = [] # List of warnings we have collected 70 self.linenum = 1 # Output line number we are up to 71 self.in_section = None # Name of start...END section we are in 72 self.notes = [] # Series notes 73 self.section = [] # The current section...END section 74 self.series = series # Info about the patch series 75 self.is_log = is_log # True if indent like git log 76 self.in_change = 0 # Non-zero if we are in a change list 77 self.blank_count = 0 # Number of blank lines stored up 78 self.state = STATE_MSG_HEADER # What state are we in? 79 self.signoff = [] # Contents of signoff line 80 self.commit = None # Current commit 81 82 def AddToSeries(self, line, name, value): 83 """Add a new Series-xxx tag. 84 85 When a Series-xxx tag is detected, we come here to record it, if we 86 are scanning a 'git log'. 87 88 Args: 89 line: Source line containing tag (useful for debug/error messages) 90 name: Tag name (part after 'Series-') 91 value: Tag value (part after 'Series-xxx: ') 92 """ 93 if name == 'notes': 94 self.in_section = name 95 self.skip_blank = False 96 if self.is_log: 97 self.series.AddTag(self.commit, line, name, value) 98 99 def AddToCommit(self, line, name, value): 100 """Add a new Commit-xxx tag. 101 102 When a Commit-xxx tag is detected, we come here to record it. 103 104 Args: 105 line: Source line containing tag (useful for debug/error messages) 106 name: Tag name (part after 'Commit-') 107 value: Tag value (part after 'Commit-xxx: ') 108 """ 109 if name == 'notes': 110 self.in_section = 'commit-' + name 111 self.skip_blank = False 112 113 def CloseCommit(self): 114 """Save the current commit into our commit list, and reset our state""" 115 if self.commit and self.is_log: 116 self.series.AddCommit(self.commit) 117 self.commit = None 118 # If 'END' is missing in a 'Cover-letter' section, and that section 119 # happens to show up at the very end of the commit message, this is 120 # the chance for us to fix it up. 121 if self.in_section == 'cover' and self.is_log: 122 self.series.cover = self.section 123 self.in_section = None 124 self.skip_blank = True 125 self.section = [] 126 127 def ProcessLine(self, line): 128 """Process a single line of a patch file or commit log 129 130 This process a line and returns a list of lines to output. The list 131 may be empty or may contain multiple output lines. 132 133 This is where all the complicated logic is located. The class's 134 state is used to move between different states and detect things 135 properly. 136 137 We can be in one of two modes: 138 self.is_log == True: This is 'git log' mode, where most output is 139 indented by 4 characters and we are scanning for tags 140 141 self.is_log == False: This is 'patch' mode, where we already have 142 all the tags, and are processing patches to remove junk we 143 don't want, and add things we think are required. 144 145 Args: 146 line: text line to process 147 148 Returns: 149 list of output lines, or [] if nothing should be output 150 """ 151 # Initially we have no output. Prepare the input line string 152 out = [] 153 line = line.rstrip('\n') 154 155 commit_match = re_commit.match(line) if self.is_log else None 156 157 if self.is_log: 158 if line[:4] == ' ': 159 line = line[4:] 160 161 # Handle state transition and skipping blank lines 162 series_tag_match = re_series_tag.match(line) 163 change_id_match = re_change_id.match(line) 164 commit_tag_match = re_commit_tag.match(line) 165 cover_match = re_cover.match(line) 166 cover_cc_match = re_cover_cc.match(line) 167 signoff_match = re_signoff.match(line) 168 tag_match = None 169 if self.state == STATE_PATCH_HEADER: 170 tag_match = re_tag.match(line) 171 is_blank = not line.strip() 172 if is_blank: 173 if (self.state == STATE_MSG_HEADER 174 or self.state == STATE_PATCH_SUBJECT): 175 self.state += 1 176 177 # We don't have a subject in the text stream of patch files 178 # It has its own line with a Subject: tag 179 if not self.is_log and self.state == STATE_PATCH_SUBJECT: 180 self.state += 1 181 elif commit_match: 182 self.state = STATE_MSG_HEADER 183 184 # If a tag is detected, or a new commit starts 185 if series_tag_match or commit_tag_match or change_id_match or \ 186 cover_match or cover_cc_match or signoff_match or \ 187 self.state == STATE_MSG_HEADER: 188 # but we are already in a section, this means 'END' is missing 189 # for that section, fix it up. 190 if self.in_section: 191 self.warn.append("Missing 'END' in section '%s'" % self.in_section) 192 if self.in_section == 'cover': 193 self.series.cover = self.section 194 elif self.in_section == 'notes': 195 if self.is_log: 196 self.series.notes += self.section 197 elif self.in_section == 'commit-notes': 198 if self.is_log: 199 self.commit.notes += self.section 200 else: 201 self.warn.append("Unknown section '%s'" % self.in_section) 202 self.in_section = None 203 self.skip_blank = True 204 self.section = [] 205 # but we are already in a change list, that means a blank line 206 # is missing, fix it up. 207 if self.in_change: 208 self.warn.append("Missing 'blank line' in section 'Series-changes'") 209 self.in_change = 0 210 211 # If we are in a section, keep collecting lines until we see END 212 if self.in_section: 213 if line == 'END': 214 if self.in_section == 'cover': 215 self.series.cover = self.section 216 elif self.in_section == 'notes': 217 if self.is_log: 218 self.series.notes += self.section 219 elif self.in_section == 'commit-notes': 220 if self.is_log: 221 self.commit.notes += self.section 222 else: 223 self.warn.append("Unknown section '%s'" % self.in_section) 224 self.in_section = None 225 self.skip_blank = True 226 self.section = [] 227 else: 228 self.section.append(line) 229 230 # Detect the commit subject 231 elif not is_blank and self.state == STATE_PATCH_SUBJECT: 232 self.commit.subject = line 233 234 # Detect the tags we want to remove, and skip blank lines 235 elif re_remove.match(line) and not commit_tag_match: 236 self.skip_blank = True 237 238 # TEST= should be the last thing in the commit, so remove 239 # everything after it 240 if line.startswith('TEST='): 241 self.found_test = True 242 elif self.skip_blank and is_blank: 243 self.skip_blank = False 244 245 # Detect the start of a cover letter section 246 elif cover_match: 247 self.in_section = 'cover' 248 self.skip_blank = False 249 250 elif cover_cc_match: 251 value = cover_cc_match.group(1) 252 self.AddToSeries(line, 'cover-cc', value) 253 254 # If we are in a change list, key collected lines until a blank one 255 elif self.in_change: 256 if is_blank: 257 # Blank line ends this change list 258 self.in_change = 0 259 elif line == '---': 260 self.in_change = 0 261 out = self.ProcessLine(line) 262 else: 263 if self.is_log: 264 self.series.AddChange(self.in_change, self.commit, line) 265 self.skip_blank = False 266 267 # Detect Series-xxx tags 268 elif series_tag_match: 269 name = series_tag_match.group(1) 270 value = series_tag_match.group(2) 271 if name == 'changes': 272 # value is the version number: e.g. 1, or 2 273 try: 274 value = int(value) 275 except ValueError as str: 276 raise ValueError("%s: Cannot decode version info '%s'" % 277 (self.commit.hash, line)) 278 self.in_change = int(value) 279 else: 280 self.AddToSeries(line, name, value) 281 self.skip_blank = True 282 283 # Detect Change-Id tags 284 elif change_id_match: 285 value = change_id_match.group(1) 286 if self.is_log: 287 if self.commit.change_id: 288 raise ValueError("%s: Two Change-Ids: '%s' vs. '%s'" % 289 (self.commit.hash, self.commit.change_id, value)) 290 self.commit.change_id = value 291 self.skip_blank = True 292 293 # Detect Commit-xxx tags 294 elif commit_tag_match: 295 name = commit_tag_match.group(1) 296 value = commit_tag_match.group(2) 297 if name == 'notes': 298 self.AddToCommit(line, name, value) 299 self.skip_blank = True 300 301 # Detect the start of a new commit 302 elif commit_match: 303 self.CloseCommit() 304 self.commit = commit.Commit(commit_match.group(1)) 305 306 # Detect tags in the commit message 307 elif tag_match: 308 # Remove Tested-by self, since few will take much notice 309 if (tag_match.group(1) == 'Tested-by' and 310 tag_match.group(2).find(os.getenv('USER') + '@') != -1): 311 self.warn.append("Ignoring %s" % line) 312 elif tag_match.group(1) == 'Patch-cc': 313 self.commit.AddCc(tag_match.group(2).split(',')) 314 else: 315 out = [line] 316 317 # Suppress duplicate signoffs 318 elif signoff_match: 319 if (self.is_log or not self.commit or 320 self.commit.CheckDuplicateSignoff(signoff_match.group(1))): 321 out = [line] 322 323 # Well that means this is an ordinary line 324 else: 325 # Look for space before tab 326 m = re_space_before_tab.match(line) 327 if m: 328 self.warn.append('Line %d/%d has space before tab' % 329 (self.linenum, m.start())) 330 331 # OK, we have a valid non-blank line 332 out = [line] 333 self.linenum += 1 334 self.skip_blank = False 335 if self.state == STATE_DIFFS: 336 pass 337 338 # If this is the start of the diffs section, emit our tags and 339 # change log 340 elif line == '---': 341 self.state = STATE_DIFFS 342 343 # Output the tags (signeoff first), then change list 344 out = [] 345 log = self.series.MakeChangeLog(self.commit) 346 out += [line] 347 if self.commit: 348 out += self.commit.notes 349 out += [''] + log 350 elif self.found_test: 351 if not re_allowed_after_test.match(line): 352 self.lines_after_test += 1 353 354 return out 355 356 def Finalize(self): 357 """Close out processing of this patch stream""" 358 self.CloseCommit() 359 if self.lines_after_test: 360 self.warn.append('Found %d lines after TEST=' % 361 self.lines_after_test) 362 363 def WriteMessageId(self, outfd): 364 """Write the Message-Id into the output. 365 366 This is based on the Change-Id in the original patch, the version, 367 and the prefix. 368 369 Args: 370 outfd: Output stream file object 371 """ 372 if not self.commit.change_id: 373 return 374 375 # If the count is -1 we're testing, so use a fixed time 376 if self.commit.count == -1: 377 time_now = datetime.datetime(1999, 12, 31, 23, 59, 59) 378 else: 379 time_now = datetime.datetime.now() 380 381 # In theory there is email.utils.make_msgid() which would be nice 382 # to use, but it already produces something way too long and thus 383 # will produce ugly commit lines if someone throws this into 384 # a "Link:" tag in the final commit. So (sigh) roll our own. 385 386 # Start with the time; presumably we wouldn't send the same series 387 # with the same Change-Id at the exact same second. 388 parts = [time_now.strftime("%Y%m%d%H%M%S")] 389 390 # These seem like they would be nice to include. 391 if 'prefix' in self.series: 392 parts.append(self.series['prefix']) 393 if 'version' in self.series: 394 parts.append("v%s" % self.series['version']) 395 396 parts.append(str(self.commit.count + 1)) 397 398 # The Change-Id must be last, right before the @ 399 parts.append(self.commit.change_id) 400 401 # Join parts together with "." and write it out. 402 outfd.write('Message-Id: <%s@changeid>\n' % '.'.join(parts)) 403 404 def ProcessStream(self, infd, outfd): 405 """Copy a stream from infd to outfd, filtering out unwanting things. 406 407 This is used to process patch files one at a time. 408 409 Args: 410 infd: Input stream file object 411 outfd: Output stream file object 412 """ 413 # Extract the filename from each diff, for nice warnings 414 fname = None 415 last_fname = None 416 re_fname = re.compile('diff --git a/(.*) b/.*') 417 418 self.WriteMessageId(outfd) 419 420 while True: 421 line = infd.readline() 422 if not line: 423 break 424 out = self.ProcessLine(line) 425 426 # Try to detect blank lines at EOF 427 for line in out: 428 match = re_fname.match(line) 429 if match: 430 last_fname = fname 431 fname = match.group(1) 432 if line == '+': 433 self.blank_count += 1 434 else: 435 if self.blank_count and (line == '-- ' or match): 436 self.warn.append("Found possible blank line(s) at " 437 "end of file '%s'" % last_fname) 438 outfd.write('+\n' * self.blank_count) 439 outfd.write(line + '\n') 440 self.blank_count = 0 441 self.Finalize() 442 443 444def GetMetaDataForList(commit_range, git_dir=None, count=None, 445 series = None, allow_overwrite=False): 446 """Reads out patch series metadata from the commits 447 448 This does a 'git log' on the relevant commits and pulls out the tags we 449 are interested in. 450 451 Args: 452 commit_range: Range of commits to count (e.g. 'HEAD..base') 453 git_dir: Path to git repositiory (None to use default) 454 count: Number of commits to list, or None for no limit 455 series: Series object to add information into. By default a new series 456 is started. 457 allow_overwrite: Allow tags to overwrite an existing tag 458 Returns: 459 A Series object containing information about the commits. 460 """ 461 if not series: 462 series = Series() 463 series.allow_overwrite = allow_overwrite 464 params = gitutil.LogCmd(commit_range, reverse=True, count=count, 465 git_dir=git_dir) 466 stdout = command.RunPipe([params], capture=True).stdout 467 ps = PatchStream(series, is_log=True) 468 for line in stdout.splitlines(): 469 ps.ProcessLine(line) 470 ps.Finalize() 471 return series 472 473def GetMetaData(start, count): 474 """Reads out patch series metadata from the commits 475 476 This does a 'git log' on the relevant commits and pulls out the tags we 477 are interested in. 478 479 Args: 480 start: Commit to start from: 0=HEAD, 1=next one, etc. 481 count: Number of commits to list 482 """ 483 return GetMetaDataForList('HEAD~%d' % start, None, count) 484 485def GetMetaDataForTest(text): 486 """Process metadata from a file containing a git log. Used for tests 487 488 Args: 489 text: 490 """ 491 series = Series() 492 ps = PatchStream(series, is_log=True) 493 for line in text.splitlines(): 494 ps.ProcessLine(line) 495 ps.Finalize() 496 return series 497 498def FixPatch(backup_dir, fname, series, commit): 499 """Fix up a patch file, by adding/removing as required. 500 501 We remove our tags from the patch file, insert changes lists, etc. 502 The patch file is processed in place, and overwritten. 503 504 A backup file is put into backup_dir (if not None). 505 506 Args: 507 fname: Filename to patch file to process 508 series: Series information about this patch set 509 commit: Commit object for this patch file 510 Return: 511 A list of errors, or [] if all ok. 512 """ 513 handle, tmpname = tempfile.mkstemp() 514 outfd = os.fdopen(handle, 'w', encoding='utf-8') 515 infd = open(fname, 'r', encoding='utf-8') 516 ps = PatchStream(series) 517 ps.commit = commit 518 ps.ProcessStream(infd, outfd) 519 infd.close() 520 outfd.close() 521 522 # Create a backup file if required 523 if backup_dir: 524 shutil.copy(fname, os.path.join(backup_dir, os.path.basename(fname))) 525 shutil.move(tmpname, fname) 526 return ps.warn 527 528def FixPatches(series, fnames): 529 """Fix up a list of patches identified by filenames 530 531 The patch files are processed in place, and overwritten. 532 533 Args: 534 series: The series object 535 fnames: List of patch files to process 536 """ 537 # Current workflow creates patches, so we shouldn't need a backup 538 backup_dir = None #tempfile.mkdtemp('clean-patch') 539 count = 0 540 for fname in fnames: 541 commit = series.commits[count] 542 commit.patch = fname 543 commit.count = count 544 result = FixPatch(backup_dir, fname, series, commit) 545 if result: 546 print('%d warnings for %s:' % (len(result), fname)) 547 for warn in result: 548 print('\t', warn) 549 print 550 count += 1 551 print('Cleaned %d patches' % count) 552 553def InsertCoverLetter(fname, series, count): 554 """Inserts a cover letter with the required info into patch 0 555 556 Args: 557 fname: Input / output filename of the cover letter file 558 series: Series object 559 count: Number of patches in the series 560 """ 561 fd = open(fname, 'r') 562 lines = fd.readlines() 563 fd.close() 564 565 fd = open(fname, 'w') 566 text = series.cover 567 prefix = series.GetPatchPrefix() 568 for line in lines: 569 if line.startswith('Subject:'): 570 # if more than 10 or 100 patches, it should say 00/xx, 000/xxx, etc 571 zero_repeat = int(math.log10(count)) + 1 572 zero = '0' * zero_repeat 573 line = 'Subject: [%s %s/%d] %s\n' % (prefix, zero, count, text[0]) 574 575 # Insert our cover letter 576 elif line.startswith('*** BLURB HERE ***'): 577 # First the blurb test 578 line = '\n'.join(text[1:]) + '\n' 579 if series.get('notes'): 580 line += '\n'.join(series.notes) + '\n' 581 582 # Now the change list 583 out = series.MakeChangeLog(None) 584 line += '\n' + '\n'.join(out) 585 fd.write(line) 586 fd.close() 587