Merge tag 'v2022.04-rc4' into next
[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 # Detect line with invalid TAG
63 RE_INV_TAG = re.compile('^Serie-([a-z-]*): *(.*)')
64
65 # States we can be in - can we use range() and still have comments?
66 STATE_MSG_HEADER = 0        # Still in the message header
67 STATE_PATCH_SUBJECT = 1     # In patch subject (first line of log for a commit)
68 STATE_PATCH_HEADER = 2      # In patch header (after the subject)
69 STATE_DIFFS = 3             # In the diff part (past --- line)
70
71 class 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.add_rtag(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.add_change(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.add_cc(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.check_duplicate_signoff(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.series['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
651 def 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
688 def 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.log_cmd(commit_range, reverse=True, count=count,
702                             git_dir=git_dir)
703     return command.run_pipe([params], capture=True).stdout
704
705 def 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
733 def 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
750 def 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
766 def 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
798 def 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
824 def 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()