xref: /openbmc/u-boot/tools/patman/gitutil.py (revision 1e52fea3)
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
22import command
23import re
24import os
25import series
26import settings
27import subprocess
28import sys
29import terminal
30
31
32def CountCommitsToBranch():
33    """Returns number of commits between HEAD and the tracking branch.
34
35    This looks back to the tracking branch and works out the number of commits
36    since then.
37
38    Return:
39        Number of patches that exist on top of the branch
40    """
41    pipe = [['git', 'log', '--oneline', '@{upstream}..'],
42            ['wc', '-l']]
43    stdout = command.RunPipe(pipe, capture=True, oneline=True)
44    patch_count = int(stdout)
45    return patch_count
46
47def CreatePatches(start, count, series):
48    """Create a series of patches from the top of the current branch.
49
50    The patch files are written to the current directory using
51    git format-patch.
52
53    Args:
54        start: Commit to start from: 0=HEAD, 1=next one, etc.
55        count: number of commits to include
56    Return:
57        Filename of cover letter
58        List of filenames of patch files
59    """
60    if series.get('version'):
61        version = '%s ' % series['version']
62    cmd = ['git', 'format-patch', '-M', '--signoff']
63    if series.get('cover'):
64        cmd.append('--cover-letter')
65    prefix = series.GetPatchPrefix()
66    if prefix:
67        cmd += ['--subject-prefix=%s' % prefix]
68    cmd += ['HEAD~%d..HEAD~%d' % (start + count, start)]
69
70    stdout = command.RunList(cmd)
71    files = stdout.splitlines()
72
73    # We have an extra file if there is a cover letter
74    if series.get('cover'):
75       return files[0], files[1:]
76    else:
77       return None, files
78
79def ApplyPatch(verbose, fname):
80    """Apply a patch with git am to test it
81
82    TODO: Convert these to use command, with stderr option
83
84    Args:
85        fname: filename of patch file to apply
86    """
87    cmd = ['git', 'am', fname]
88    pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE,
89            stderr=subprocess.PIPE)
90    stdout, stderr = pipe.communicate()
91    re_error = re.compile('^error: patch failed: (.+):(\d+)')
92    for line in stderr.splitlines():
93        if verbose:
94            print line
95        match = re_error.match(line)
96        if match:
97            print GetWarningMsg('warning', match.group(1), int(match.group(2)),
98                    'Patch failed')
99    return pipe.returncode == 0, stdout
100
101def ApplyPatches(verbose, args, start_point):
102    """Apply the patches with git am to make sure all is well
103
104    Args:
105        verbose: Print out 'git am' output verbatim
106        args: List of patch files to apply
107        start_point: Number of commits back from HEAD to start applying.
108            Normally this is len(args), but it can be larger if a start
109            offset was given.
110    """
111    error_count = 0
112    col = terminal.Color()
113
114    # Figure out our current position
115    cmd = ['git', 'name-rev', 'HEAD', '--name-only']
116    pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE)
117    stdout, stderr = pipe.communicate()
118    if pipe.returncode:
119        str = 'Could not find current commit name'
120        print col.Color(col.RED, str)
121        print stdout
122        return False
123    old_head = stdout.splitlines()[0]
124
125    # Checkout the required start point
126    cmd = ['git', 'checkout', 'HEAD~%d' % start_point]
127    pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE,
128            stderr=subprocess.PIPE)
129    stdout, stderr = pipe.communicate()
130    if pipe.returncode:
131        str = 'Could not move to commit before patch series'
132        print col.Color(col.RED, str)
133        print stdout, stderr
134        return False
135
136    # Apply all the patches
137    for fname in args:
138        ok, stdout = ApplyPatch(verbose, fname)
139        if not ok:
140            print col.Color(col.RED, 'git am returned errors for %s: will '
141                    'skip this patch' % fname)
142            if verbose:
143                print stdout
144            error_count += 1
145            cmd = ['git', 'am', '--skip']
146            pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE)
147            stdout, stderr = pipe.communicate()
148            if pipe.returncode != 0:
149                print col.Color(col.RED, 'Unable to skip patch! Aborting...')
150                print stdout
151                break
152
153    # Return to our previous position
154    cmd = ['git', 'checkout', old_head]
155    pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
156    stdout, stderr = pipe.communicate()
157    if pipe.returncode:
158        print col.Color(col.RED, 'Could not move back to head commit')
159        print stdout, stderr
160    return error_count == 0
161
162def BuildEmailList(in_list, tag=None, alias=None):
163    """Build a list of email addresses based on an input list.
164
165    Takes a list of email addresses and aliases, and turns this into a list
166    of only email address, by resolving any aliases that are present.
167
168    If the tag is given, then each email address is prepended with this
169    tag and a space. If the tag starts with a minus sign (indicating a
170    command line parameter) then the email address is quoted.
171
172    Args:
173        in_list:        List of aliases/email addresses
174        tag:            Text to put before each address
175
176    Returns:
177        List of email addresses
178
179    >>> alias = {}
180    >>> alias['fred'] = ['f.bloggs@napier.co.nz']
181    >>> alias['john'] = ['j.bloggs@napier.co.nz']
182    >>> alias['mary'] = ['Mary Poppins <m.poppins@cloud.net>']
183    >>> alias['boys'] = ['fred', ' john']
184    >>> alias['all'] = ['fred ', 'john', '   mary   ']
185    >>> BuildEmailList(['john', 'mary'], None, alias)
186    ['j.bloggs@napier.co.nz', 'Mary Poppins <m.poppins@cloud.net>']
187    >>> BuildEmailList(['john', 'mary'], '--to', alias)
188    ['--to "j.bloggs@napier.co.nz"', \
189'--to "Mary Poppins <m.poppins@cloud.net>"']
190    >>> BuildEmailList(['john', 'mary'], 'Cc', alias)
191    ['Cc j.bloggs@napier.co.nz', 'Cc Mary Poppins <m.poppins@cloud.net>']
192    """
193    quote = '"' if tag and tag[0] == '-' else ''
194    raw = []
195    for item in in_list:
196        raw += LookupEmail(item, alias)
197    result = []
198    for item in raw:
199        if not item in result:
200            result.append(item)
201    if tag:
202        return ['%s %s%s%s' % (tag, quote, email, quote) for email in result]
203    return result
204
205def EmailPatches(series, cover_fname, args, dry_run, cc_fname,
206        self_only=False, alias=None):
207    """Email a patch series.
208
209    Args:
210        series: Series object containing destination info
211        cover_fname: filename of cover letter
212        args: list of filenames of patch files
213        dry_run: Just return the command that would be run
214        cc_fname: Filename of Cc file for per-commit Cc
215        self_only: True to just email to yourself as a test
216
217    Returns:
218        Git command that was/would be run
219
220    >>> alias = {}
221    >>> alias['fred'] = ['f.bloggs@napier.co.nz']
222    >>> alias['john'] = ['j.bloggs@napier.co.nz']
223    >>> alias['mary'] = ['m.poppins@cloud.net']
224    >>> alias['boys'] = ['fred', ' john']
225    >>> alias['all'] = ['fred ', 'john', '   mary   ']
226    >>> alias[os.getenv('USER')] = ['this-is-me@me.com']
227    >>> series = series.Series()
228    >>> series.to = ['fred']
229    >>> series.cc = ['mary']
230    >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, 'cc-fname', False, \
231            alias)
232    'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \
233"m.poppins@cloud.net" --cc-cmd "./patman --cc-cmd cc-fname" cover p1 p2'
234    >>> EmailPatches(series, None, ['p1'], True, 'cc-fname', False, alias)
235    'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \
236"m.poppins@cloud.net" --cc-cmd "./patman --cc-cmd cc-fname" p1'
237    >>> series.cc = ['all']
238    >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, 'cc-fname', True, \
239            alias)
240    'git send-email --annotate --to "this-is-me@me.com" --cc-cmd "./patman \
241--cc-cmd cc-fname" cover p1 p2'
242    >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, 'cc-fname', False, \
243            alias)
244    'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \
245"f.bloggs@napier.co.nz" --cc "j.bloggs@napier.co.nz" --cc \
246"m.poppins@cloud.net" --cc-cmd "./patman --cc-cmd cc-fname" cover p1 p2'
247    """
248    to = BuildEmailList(series.get('to'), '--to', alias)
249    if not to:
250        print ("No recipient, please add something like this to a commit\n"
251            "Series-to: Fred Bloggs <f.blogs@napier.co.nz>")
252        return
253    cc = BuildEmailList(series.get('cc'), '--cc', alias)
254    if self_only:
255        to = BuildEmailList([os.getenv('USER')], '--to', alias)
256        cc = []
257    cmd = ['git', 'send-email', '--annotate']
258    cmd += to
259    cmd += cc
260    cmd += ['--cc-cmd', '"%s --cc-cmd %s"' % (sys.argv[0], cc_fname)]
261    if cover_fname:
262        cmd.append(cover_fname)
263    cmd += args
264    str = ' '.join(cmd)
265    if not dry_run:
266        os.system(str)
267    return str
268
269
270def LookupEmail(lookup_name, alias=None, level=0):
271    """If an email address is an alias, look it up and return the full name
272
273    TODO: Why not just use git's own alias feature?
274
275    Args:
276        lookup_name: Alias or email address to look up
277
278    Returns:
279        tuple:
280            list containing a list of email addresses
281
282    Raises:
283        OSError if a recursive alias reference was found
284        ValueError if an alias was not found
285
286    >>> alias = {}
287    >>> alias['fred'] = ['f.bloggs@napier.co.nz']
288    >>> alias['john'] = ['j.bloggs@napier.co.nz']
289    >>> alias['mary'] = ['m.poppins@cloud.net']
290    >>> alias['boys'] = ['fred', ' john', 'f.bloggs@napier.co.nz']
291    >>> alias['all'] = ['fred ', 'john', '   mary   ']
292    >>> alias['loop'] = ['other', 'john', '   mary   ']
293    >>> alias['other'] = ['loop', 'john', '   mary   ']
294    >>> LookupEmail('mary', alias)
295    ['m.poppins@cloud.net']
296    >>> LookupEmail('arthur.wellesley@howe.ro.uk', alias)
297    ['arthur.wellesley@howe.ro.uk']
298    >>> LookupEmail('boys', alias)
299    ['f.bloggs@napier.co.nz', 'j.bloggs@napier.co.nz']
300    >>> LookupEmail('all', alias)
301    ['f.bloggs@napier.co.nz', 'j.bloggs@napier.co.nz', 'm.poppins@cloud.net']
302    >>> LookupEmail('odd', alias)
303    Traceback (most recent call last):
304    ...
305    ValueError: Alias 'odd' not found
306    >>> LookupEmail('loop', alias)
307    Traceback (most recent call last):
308    ...
309    OSError: Recursive email alias at 'other'
310    """
311    if not alias:
312        alias = settings.alias
313    lookup_name = lookup_name.strip()
314    if '@' in lookup_name: # Perhaps a real email address
315        return [lookup_name]
316
317    lookup_name = lookup_name.lower()
318
319    if level > 10:
320        raise OSError, "Recursive email alias at '%s'" % lookup_name
321
322    out_list = []
323    if lookup_name:
324        if not lookup_name in alias:
325            raise ValueError, "Alias '%s' not found" % lookup_name
326        for item in alias[lookup_name]:
327            todo = LookupEmail(item, alias, level + 1)
328            for new_item in todo:
329                if not new_item in out_list:
330                    out_list.append(new_item)
331
332    #print "No match for alias '%s'" % lookup_name
333    return out_list
334
335def GetTopLevel():
336    """Return name of top-level directory for this git repo.
337
338    Returns:
339        Full path to git top-level directory
340
341    This test makes sure that we are running tests in the right subdir
342
343    >>> os.path.realpath(os.getcwd()) == \
344            os.path.join(GetTopLevel(), 'tools', 'scripts', 'patman')
345    True
346    """
347    return command.OutputOneLine('git', 'rev-parse', '--show-toplevel')
348
349def GetAliasFile():
350    """Gets the name of the git alias file.
351
352    Returns:
353        Filename of git alias file, or None if none
354    """
355    fname = command.OutputOneLine('git', 'config', 'sendemail.aliasesfile')
356    if fname:
357        fname = os.path.join(GetTopLevel(), fname.strip())
358    return fname
359
360def Setup():
361    """Set up git utils, by reading the alias files."""
362    settings.Setup('')
363
364    # Check for a git alias file also
365    alias_fname = GetAliasFile()
366    if alias_fname:
367        settings.ReadGitAliases(alias_fname)
368
369if __name__ == "__main__":
370    import doctest
371
372    doctest.testmod()
373