Merge https://gitlab.denx.de/u-boot/custodians/u-boot-marvell
[oweals/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 import command
13 import commit
14 import gitutil
15 from 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 # The start of the cover letter
28 re_cover = re.compile('^Cover-letter:')
29
30 # A cover letter Cc
31 re_cover_cc = re.compile('^Cover-letter-cc: *(.*)')
32
33 # Patch series tag
34 re_series_tag = re.compile('^Series-([a-z-]*): *(.*)')
35
36 # Change-Id will be used to generate the Message-Id and then be stripped
37 re_change_id = re.compile('^Change-Id: *(.*)')
38
39 # Commit series tag
40 re_commit_tag = re.compile('^Commit-([a-z-]*): *(.*)')
41
42 # Commit tags that we want to collect and keep
43 re_tag = re.compile('^(Tested-by|Acked-by|Reviewed-by|Patch-cc): (.*)')
44
45 # The start of a new commit in the git log
46 re_commit = re.compile('^commit ([0-9a-f]*)$')
47
48 # We detect these since checkpatch doesn't always do it
49 re_space_before_tab = re.compile('^[+].* \t')
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        # 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
444 def 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
473 def 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
485 def 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
498 def 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
528 def 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
553 def 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()