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