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