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