]> git.kernelconcepts.de Git - karo-tx-uboot.git/blob - tools/patman/patchstream.py
Add 'patman' patch generation, checking and submission script
[karo-tx-uboot.git] / tools / patman / patchstream.py
1 # Copyright (c) 2011 The Chromium OS Authors.
2 #
3 # See file CREDITS for list of people who contributed to this
4 # project.
5 #
6 # This program is free software; you can redistribute it and/or
7 # modify it under the terms of the GNU General Public License as
8 # published by the Free Software Foundation; either version 2 of
9 # the License, or (at your option) any later version.
10 #
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 # GNU General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 59 Temple Place, Suite 330, Boston,
19 # MA 02111-1307 USA
20 #
21
22 import os
23 import re
24 import shutil
25 import tempfile
26
27 import command
28 import commit
29 import gitutil
30 from series import Series
31
32 # Tags that we detect and remove
33 re_remove = re.compile('^BUG=|^TEST=|^Change-Id:|^Review URL:'
34     '|Reviewed-on:|Reviewed-by:')
35
36 # Lines which are allowed after a TEST= line
37 re_allowed_after_test = re.compile('^Signed-off-by:')
38
39 # The start of the cover letter
40 re_cover = re.compile('^Cover-letter:')
41
42 # Patch series tag
43 re_series = re.compile('^Series-(\w*): *(.*)')
44
45 # Commit tags that we want to collect and keep
46 re_tag = re.compile('^(Tested-by|Acked-by|Signed-off-by|Cc): (.*)')
47
48 # The start of a new commit in the git log
49 re_commit = re.compile('^commit (.*)')
50
51 # We detect these since checkpatch doesn't always do it
52 re_space_before_tab = re.compile('^[+].* \t')
53
54 # States we can be in - can we use range() and still have comments?
55 STATE_MSG_HEADER = 0        # Still in the message header
56 STATE_PATCH_SUBJECT = 1     # In patch subject (first line of log for a commit)
57 STATE_PATCH_HEADER = 2      # In patch header (after the subject)
58 STATE_DIFFS = 3             # In the diff part (past --- line)
59
60 class PatchStream:
61     """Class for detecting/injecting tags in a patch or series of patches
62
63     We support processing the output of 'git log' to read out the tags we
64     are interested in. We can also process a patch file in order to remove
65     unwanted tags or inject additional ones. These correspond to the two
66     phases of processing.
67     """
68     def __init__(self, series, name=None, is_log=False):
69         self.skip_blank = False          # True to skip a single blank line
70         self.found_test = False          # Found a TEST= line
71         self.lines_after_test = 0        # MNumber of lines found after TEST=
72         self.warn = []                   # List of warnings we have collected
73         self.linenum = 1                 # Output line number we are up to
74         self.in_section = None           # Name of start...END section we are in
75         self.notes = []                  # Series notes
76         self.section = []                # The current section...END section
77         self.series = series             # Info about the patch series
78         self.is_log = is_log             # True if indent like git log
79         self.in_change = 0               # Non-zero if we are in a change list
80         self.blank_count = 0             # Number of blank lines stored up
81         self.state = STATE_MSG_HEADER    # What state are we in?
82         self.tags = []                   # Tags collected, like Tested-by...
83         self.signoff = []                # Contents of signoff line
84         self.commit = None               # Current commit
85
86     def AddToSeries(self, line, name, value):
87         """Add a new Series-xxx tag.
88
89         When a Series-xxx tag is detected, we come here to record it, if we
90         are scanning a 'git log'.
91
92         Args:
93             line: Source line containing tag (useful for debug/error messages)
94             name: Tag name (part after 'Series-')
95             value: Tag value (part after 'Series-xxx: ')
96         """
97         if name == 'notes':
98             self.in_section = name
99             self.skip_blank = False
100         if self.is_log:
101             self.series.AddTag(self.commit, line, name, value)
102
103     def CloseCommit(self):
104         """Save the current commit into our commit list, and reset our state"""
105         if self.commit and self.is_log:
106             self.series.AddCommit(self.commit)
107             self.commit = None
108
109     def FormatTags(self, tags):
110         out_list = []
111         for tag in sorted(tags):
112             if tag.startswith('Cc:'):
113                 tag_list = tag[4:].split(',')
114                 out_list += gitutil.BuildEmailList(tag_list, 'Cc:')
115             else:
116                 out_list.append(tag)
117         return out_list
118
119     def ProcessLine(self, line):
120         """Process a single line of a patch file or commit log
121
122         This process a line and returns a list of lines to output. The list
123         may be empty or may contain multiple output lines.
124
125         This is where all the complicated logic is located. The class's
126         state is used to move between different states and detect things
127         properly.
128
129         We can be in one of two modes:
130             self.is_log == True: This is 'git log' mode, where most output is
131                 indented by 4 characters and we are scanning for tags
132
133             self.is_log == False: This is 'patch' mode, where we already have
134                 all the tags, and are processing patches to remove junk we
135                 don't want, and add things we think are required.
136
137         Args:
138             line: text line to process
139
140         Returns:
141             list of output lines, or [] if nothing should be output
142         """
143         # Initially we have no output. Prepare the input line string
144         out = []
145         line = line.rstrip('\n')
146         if self.is_log:
147             if line[:4] == '    ':
148                 line = line[4:]
149
150         # Handle state transition and skipping blank lines
151         series_match = re_series.match(line)
152         commit_match = re_commit.match(line) if self.is_log else None
153         tag_match = None
154         if self.state == STATE_PATCH_HEADER:
155             tag_match = re_tag.match(line)
156         is_blank = not line.strip()
157         if is_blank:
158             if (self.state == STATE_MSG_HEADER
159                     or self.state == STATE_PATCH_SUBJECT):
160                 self.state += 1
161
162             # We don't have a subject in the text stream of patch files
163             # It has its own line with a Subject: tag
164             if not self.is_log and self.state == STATE_PATCH_SUBJECT:
165                 self.state += 1
166         elif commit_match:
167             self.state = STATE_MSG_HEADER
168
169         # If we are in a section, keep collecting lines until we see END
170         if self.in_section:
171             if line == 'END':
172                 if self.in_section == 'cover':
173                     self.series.cover = self.section
174                 elif self.in_section == 'notes':
175                     if self.is_log:
176                         self.series.notes += self.section
177                 else:
178                     self.warn.append("Unknown section '%s'" % self.in_section)
179                 self.in_section = None
180                 self.skip_blank = True
181                 self.section = []
182             else:
183                 self.section.append(line)
184
185         # Detect the commit subject
186         elif not is_blank and self.state == STATE_PATCH_SUBJECT:
187             self.commit.subject = line
188
189         # Detect the tags we want to remove, and skip blank lines
190         elif re_remove.match(line):
191             self.skip_blank = True
192
193             # TEST= should be the last thing in the commit, so remove
194             # everything after it
195             if line.startswith('TEST='):
196                 self.found_test = True
197         elif self.skip_blank and is_blank:
198             self.skip_blank = False
199
200         # Detect the start of a cover letter section
201         elif re_cover.match(line):
202             self.in_section = 'cover'
203             self.skip_blank = False
204
205         # If we are in a change list, key collected lines until a blank one
206         elif self.in_change:
207             if is_blank:
208                 # Blank line ends this change list
209                 self.in_change = 0
210             else:
211                 self.series.AddChange(self.in_change, self.commit, line)
212             self.skip_blank = False
213
214         # Detect Series-xxx tags
215         elif series_match:
216             name = series_match.group(1)
217             value = series_match.group(2)
218             if name == 'changes':
219                 # value is the version number: e.g. 1, or 2
220                 try:
221                     value = int(value)
222                 except ValueError as str:
223                     raise ValueError("%s: Cannot decode version info '%s'" %
224                         (self.commit.hash, line))
225                 self.in_change = int(value)
226             else:
227                 self.AddToSeries(line, name, value)
228                 self.skip_blank = True
229
230         # Detect the start of a new commit
231         elif commit_match:
232             self.CloseCommit()
233             self.commit = commit.Commit(commit_match.group(1)[:7])
234
235         # Detect tags in the commit message
236         elif tag_match:
237             # Onlly allow a single signoff tag
238             if tag_match.group(1) == 'Signed-off-by':
239                 if self.signoff:
240                     self.warn.append('Patch has more than one Signed-off-by '
241                             'tag')
242                 self.signoff += [line]
243
244             # Remove Tested-by self, since few will take much notice
245             elif (tag_match.group(1) == 'Tested-by' and
246                     tag_match.group(2).find(os.getenv('USER') + '@') != -1):
247                 self.warn.append("Ignoring %s" % line)
248             elif tag_match.group(1) == 'Cc':
249                 self.commit.AddCc(tag_match.group(2).split(','))
250             else:
251                 self.tags.append(line);
252
253         # Well that means this is an ordinary line
254         else:
255             pos = 1
256             # Look for ugly ASCII characters
257             for ch in line:
258                 # TODO: Would be nicer to report source filename and line
259                 if ord(ch) > 0x80:
260                     self.warn.append("Line %d/%d ('%s') has funny ascii char" %
261                         (self.linenum, pos, line))
262                 pos += 1
263
264             # Look for space before tab
265             m = re_space_before_tab.match(line)
266             if m:
267                 self.warn.append('Line %d/%d has space before tab' %
268                     (self.linenum, m.start()))
269
270             # OK, we have a valid non-blank line
271             out = [line]
272             self.linenum += 1
273             self.skip_blank = False
274             if self.state == STATE_DIFFS:
275                 pass
276
277             # If this is the start of the diffs section, emit our tags and
278             # change log
279             elif line == '---':
280                 self.state = STATE_DIFFS
281
282                 # Output the tags (signeoff first), then change list
283                 out = []
284                 if self.signoff:
285                     out += self.signoff
286                 log = self.series.MakeChangeLog(self.commit)
287                 out += self.FormatTags(self.tags)
288                 out += [line] + log
289             elif self.found_test:
290                 if not re_allowed_after_test.match(line):
291                     self.lines_after_test += 1
292
293         return out
294
295     def Finalize(self):
296         """Close out processing of this patch stream"""
297         self.CloseCommit()
298         if self.lines_after_test:
299             self.warn.append('Found %d lines after TEST=' %
300                     self.lines_after_test)
301
302     def ProcessStream(self, infd, outfd):
303         """Copy a stream from infd to outfd, filtering out unwanting things.
304
305         This is used to process patch files one at a time.
306
307         Args:
308             infd: Input stream file object
309             outfd: Output stream file object
310         """
311         # Extract the filename from each diff, for nice warnings
312         fname = None
313         last_fname = None
314         re_fname = re.compile('diff --git a/(.*) b/.*')
315         while True:
316             line = infd.readline()
317             if not line:
318                 break
319             out = self.ProcessLine(line)
320
321             # Try to detect blank lines at EOF
322             for line in out:
323                 match = re_fname.match(line)
324                 if match:
325                     last_fname = fname
326                     fname = match.group(1)
327                 if line == '+':
328                     self.blank_count += 1
329                 else:
330                     if self.blank_count and (line == '-- ' or match):
331                         self.warn.append("Found possible blank line(s) at "
332                                 "end of file '%s'" % last_fname)
333                     outfd.write('+\n' * self.blank_count)
334                     outfd.write(line + '\n')
335                     self.blank_count = 0
336         self.Finalize()
337
338
339 def GetMetaData(start, count):
340     """Reads out patch series metadata from the commits
341
342     This does a 'git log' on the relevant commits and pulls out the tags we
343     are interested in.
344
345     Args:
346         start: Commit to start from: 0=HEAD, 1=next one, etc.
347         count: Number of commits to list
348     """
349     pipe = [['git', 'log', '--reverse', 'HEAD~%d' % start, '-n%d' % count]]
350     stdout = command.RunPipe(pipe, capture=True)
351     series = Series()
352     ps = PatchStream(series, is_log=True)
353     for line in stdout.splitlines():
354         ps.ProcessLine(line)
355     ps.Finalize()
356     return series
357
358 def FixPatch(backup_dir, fname, series, commit):
359     """Fix up a patch file, by adding/removing as required.
360
361     We remove our tags from the patch file, insert changes lists, etc.
362     The patch file is processed in place, and overwritten.
363
364     A backup file is put into backup_dir (if not None).
365
366     Args:
367         fname: Filename to patch file to process
368         series: Series information about this patch set
369         commit: Commit object for this patch file
370     Return:
371         A list of errors, or [] if all ok.
372     """
373     handle, tmpname = tempfile.mkstemp()
374     outfd = os.fdopen(handle, 'w')
375     infd = open(fname, 'r')
376     ps = PatchStream(series)
377     ps.commit = commit
378     ps.ProcessStream(infd, outfd)
379     infd.close()
380     outfd.close()
381
382     # Create a backup file if required
383     if backup_dir:
384         shutil.copy(fname, os.path.join(backup_dir, os.path.basename(fname)))
385     shutil.move(tmpname, fname)
386     return ps.warn
387
388 def FixPatches(series, fnames):
389     """Fix up a list of patches identified by filenames
390
391     The patch files are processed in place, and overwritten.
392
393     Args:
394         series: The series object
395         fnames: List of patch files to process
396     """
397     # Current workflow creates patches, so we shouldn't need a backup
398     backup_dir = None  #tempfile.mkdtemp('clean-patch')
399     count = 0
400     for fname in fnames:
401         commit = series.commits[count]
402         commit.patch = fname
403         result = FixPatch(backup_dir, fname, series, commit)
404         if result:
405             print '%d warnings for %s:' % (len(result), fname)
406             for warn in result:
407                 print '\t', warn
408             print
409         count += 1
410     print 'Cleaned %d patches' % count
411     return series
412
413 def InsertCoverLetter(fname, series, count):
414     """Inserts a cover letter with the required info into patch 0
415
416     Args:
417         fname: Input / output filename of the cover letter file
418         series: Series object
419         count: Number of patches in the series
420     """
421     fd = open(fname, 'r')
422     lines = fd.readlines()
423     fd.close()
424
425     fd = open(fname, 'w')
426     text = series.cover
427     prefix = series.GetPatchPrefix()
428     for line in lines:
429         if line.startswith('Subject:'):
430             # TODO: if more than 10 patches this should save 00/xx, not 0/xx
431             line = 'Subject: [%s 0/%d] %s\n' % (prefix, count, text[0])
432
433         # Insert our cover letter
434         elif line.startswith('*** BLURB HERE ***'):
435             # First the blurb test
436             line = '\n'.join(text[1:]) + '\n'
437             if series.get('notes'):
438                 line += '\n'.join(series.notes) + '\n'
439
440             # Now the change list
441             out = series.MakeChangeLog(None)
442             line += '\n' + '\n'.join(out)
443         fd.write(line)
444     fd.close()