xref: /openbmc/u-boot/tools/patman/gitutil.py (revision 198a40b9)
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 subprocess
27import sys
28import terminal
29
30import settings
31
32
33def CountCommitsToBranch():
34    """Returns number of commits between HEAD and the tracking branch.
35
36    This looks back to the tracking branch and works out the number of commits
37    since then.
38
39    Return:
40        Number of patches that exist on top of the branch
41    """
42    pipe = [['git', 'log', '--no-color', '--oneline', '--no-decorate',
43             '@{upstream}..'],
44            ['wc', '-l']]
45    stdout = command.RunPipe(pipe, capture=True, oneline=True).stdout
46    patch_count = int(stdout)
47    return patch_count
48
49def GetUpstream(git_dir, branch):
50    """Returns the name of the upstream for a branch
51
52    Args:
53        git_dir: Git directory containing repo
54        branch: Name of branch
55
56    Returns:
57        Name of upstream branch (e.g. 'upstream/master') or None if none
58    """
59    try:
60        remote = command.OutputOneLine('git', '--git-dir', git_dir, 'config',
61                                       'branch.%s.remote' % branch)
62        merge = command.OutputOneLine('git', '--git-dir', git_dir, 'config',
63                                      'branch.%s.merge' % branch)
64    except:
65        return None
66
67    if remote == '.':
68        return merge
69    elif remote and merge:
70        leaf = merge.split('/')[-1]
71        return '%s/%s' % (remote, leaf)
72    else:
73        raise ValueError, ("Cannot determine upstream branch for branch "
74                "'%s' remote='%s', merge='%s'" % (branch, remote, merge))
75
76
77def GetRangeInBranch(git_dir, branch, include_upstream=False):
78    """Returns an expression for the commits in the given branch.
79
80    Args:
81        git_dir: Directory containing git repo
82        branch: Name of branch
83    Return:
84        Expression in the form 'upstream..branch' which can be used to
85        access the commits. If the branch does not exist, returns None.
86    """
87    upstream = GetUpstream(git_dir, branch)
88    if not upstream:
89        return None
90    return '%s%s..%s' % (upstream, '~' if include_upstream else '', branch)
91
92def CountCommitsInBranch(git_dir, branch, include_upstream=False):
93    """Returns the number of commits in the given branch.
94
95    Args:
96        git_dir: Directory containing git repo
97        branch: Name of branch
98    Return:
99        Number of patches that exist on top of the branch, or None if the
100        branch does not exist.
101    """
102    range_expr = GetRangeInBranch(git_dir, branch, include_upstream)
103    if not range_expr:
104        return None
105    pipe = [['git', '--git-dir', git_dir, 'log', '--oneline', '--no-decorate',
106             range_expr],
107            ['wc', '-l']]
108    result = command.RunPipe(pipe, capture=True, oneline=True)
109    patch_count = int(result.stdout)
110    return patch_count
111
112def CountCommits(commit_range):
113    """Returns the number of commits in the given range.
114
115    Args:
116        commit_range: Range of commits to count (e.g. 'HEAD..base')
117    Return:
118        Number of patches that exist on top of the branch
119    """
120    pipe = [['git', 'log', '--oneline', '--no-decorate', commit_range],
121            ['wc', '-l']]
122    stdout = command.RunPipe(pipe, capture=True, oneline=True).stdout
123    patch_count = int(stdout)
124    return patch_count
125
126def Checkout(commit_hash, git_dir=None, work_tree=None, force=False):
127    """Checkout the selected commit for this build
128
129    Args:
130        commit_hash: Commit hash to check out
131    """
132    pipe = ['git']
133    if git_dir:
134        pipe.extend(['--git-dir', git_dir])
135    if work_tree:
136        pipe.extend(['--work-tree', work_tree])
137    pipe.append('checkout')
138    if force:
139        pipe.append('-f')
140    pipe.append(commit_hash)
141    result = command.RunPipe([pipe], capture=True, raise_on_error=False)
142    if result.return_code != 0:
143        raise OSError, 'git checkout (%s): %s' % (pipe, result.stderr)
144
145def Clone(git_dir, output_dir):
146    """Checkout the selected commit for this build
147
148    Args:
149        commit_hash: Commit hash to check out
150    """
151    pipe = ['git', 'clone', git_dir, '.']
152    result = command.RunPipe([pipe], capture=True, cwd=output_dir)
153    if result.return_code != 0:
154        raise OSError, 'git clone: %s' % result.stderr
155
156def Fetch(git_dir=None, work_tree=None):
157    """Fetch from the origin repo
158
159    Args:
160        commit_hash: Commit hash to check out
161    """
162    pipe = ['git']
163    if git_dir:
164        pipe.extend(['--git-dir', git_dir])
165    if work_tree:
166        pipe.extend(['--work-tree', work_tree])
167    pipe.append('fetch')
168    result = command.RunPipe([pipe], capture=True)
169    if result.return_code != 0:
170        raise OSError, 'git fetch: %s' % result.stderr
171
172def CreatePatches(start, count, series):
173    """Create a series of patches from the top of the current branch.
174
175    The patch files are written to the current directory using
176    git format-patch.
177
178    Args:
179        start: Commit to start from: 0=HEAD, 1=next one, etc.
180        count: number of commits to include
181    Return:
182        Filename of cover letter
183        List of filenames of patch files
184    """
185    if series.get('version'):
186        version = '%s ' % series['version']
187    cmd = ['git', 'format-patch', '-M', '--signoff']
188    if series.get('cover'):
189        cmd.append('--cover-letter')
190    prefix = series.GetPatchPrefix()
191    if prefix:
192        cmd += ['--subject-prefix=%s' % prefix]
193    cmd += ['HEAD~%d..HEAD~%d' % (start + count, start)]
194
195    stdout = command.RunList(cmd)
196    files = stdout.splitlines()
197
198    # We have an extra file if there is a cover letter
199    if series.get('cover'):
200       return files[0], files[1:]
201    else:
202       return None, files
203
204def ApplyPatch(verbose, fname):
205    """Apply a patch with git am to test it
206
207    TODO: Convert these to use command, with stderr option
208
209    Args:
210        fname: filename of patch file to apply
211    """
212    cmd = ['git', 'am', fname]
213    pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE,
214            stderr=subprocess.PIPE)
215    stdout, stderr = pipe.communicate()
216    re_error = re.compile('^error: patch failed: (.+):(\d+)')
217    for line in stderr.splitlines():
218        if verbose:
219            print line
220        match = re_error.match(line)
221        if match:
222            print GetWarningMsg('warning', match.group(1), int(match.group(2)),
223                    'Patch failed')
224    return pipe.returncode == 0, stdout
225
226def ApplyPatches(verbose, args, start_point):
227    """Apply the patches with git am to make sure all is well
228
229    Args:
230        verbose: Print out 'git am' output verbatim
231        args: List of patch files to apply
232        start_point: Number of commits back from HEAD to start applying.
233            Normally this is len(args), but it can be larger if a start
234            offset was given.
235    """
236    error_count = 0
237    col = terminal.Color()
238
239    # Figure out our current position
240    cmd = ['git', 'name-rev', 'HEAD', '--name-only']
241    pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE)
242    stdout, stderr = pipe.communicate()
243    if pipe.returncode:
244        str = 'Could not find current commit name'
245        print col.Color(col.RED, str)
246        print stdout
247        return False
248    old_head = stdout.splitlines()[0]
249
250    # Checkout the required start point
251    cmd = ['git', 'checkout', 'HEAD~%d' % start_point]
252    pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE,
253            stderr=subprocess.PIPE)
254    stdout, stderr = pipe.communicate()
255    if pipe.returncode:
256        str = 'Could not move to commit before patch series'
257        print col.Color(col.RED, str)
258        print stdout, stderr
259        return False
260
261    # Apply all the patches
262    for fname in args:
263        ok, stdout = ApplyPatch(verbose, fname)
264        if not ok:
265            print col.Color(col.RED, 'git am returned errors for %s: will '
266                    'skip this patch' % fname)
267            if verbose:
268                print stdout
269            error_count += 1
270            cmd = ['git', 'am', '--skip']
271            pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE)
272            stdout, stderr = pipe.communicate()
273            if pipe.returncode != 0:
274                print col.Color(col.RED, 'Unable to skip patch! Aborting...')
275                print stdout
276                break
277
278    # Return to our previous position
279    cmd = ['git', 'checkout', old_head]
280    pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
281    stdout, stderr = pipe.communicate()
282    if pipe.returncode:
283        print col.Color(col.RED, 'Could not move back to head commit')
284        print stdout, stderr
285    return error_count == 0
286
287def BuildEmailList(in_list, tag=None, alias=None, raise_on_error=True):
288    """Build a list of email addresses based on an input list.
289
290    Takes a list of email addresses and aliases, and turns this into a list
291    of only email address, by resolving any aliases that are present.
292
293    If the tag is given, then each email address is prepended with this
294    tag and a space. If the tag starts with a minus sign (indicating a
295    command line parameter) then the email address is quoted.
296
297    Args:
298        in_list:        List of aliases/email addresses
299        tag:            Text to put before each address
300        alias:          Alias dictionary
301        raise_on_error: True to raise an error when an alias fails to match,
302                False to just print a message.
303
304    Returns:
305        List of email addresses
306
307    >>> alias = {}
308    >>> alias['fred'] = ['f.bloggs@napier.co.nz']
309    >>> alias['john'] = ['j.bloggs@napier.co.nz']
310    >>> alias['mary'] = ['Mary Poppins <m.poppins@cloud.net>']
311    >>> alias['boys'] = ['fred', ' john']
312    >>> alias['all'] = ['fred ', 'john', '   mary   ']
313    >>> BuildEmailList(['john', 'mary'], None, alias)
314    ['j.bloggs@napier.co.nz', 'Mary Poppins <m.poppins@cloud.net>']
315    >>> BuildEmailList(['john', 'mary'], '--to', alias)
316    ['--to "j.bloggs@napier.co.nz"', \
317'--to "Mary Poppins <m.poppins@cloud.net>"']
318    >>> BuildEmailList(['john', 'mary'], 'Cc', alias)
319    ['Cc j.bloggs@napier.co.nz', 'Cc Mary Poppins <m.poppins@cloud.net>']
320    """
321    quote = '"' if tag and tag[0] == '-' else ''
322    raw = []
323    for item in in_list:
324        raw += LookupEmail(item, alias, raise_on_error=raise_on_error)
325    result = []
326    for item in raw:
327        if not item in result:
328            result.append(item)
329    if tag:
330        return ['%s %s%s%s' % (tag, quote, email, quote) for email in result]
331    return result
332
333def EmailPatches(series, cover_fname, args, dry_run, raise_on_error, cc_fname,
334        self_only=False, alias=None, in_reply_to=None):
335    """Email a patch series.
336
337    Args:
338        series: Series object containing destination info
339        cover_fname: filename of cover letter
340        args: list of filenames of patch files
341        dry_run: Just return the command that would be run
342        raise_on_error: True to raise an error when an alias fails to match,
343                False to just print a message.
344        cc_fname: Filename of Cc file for per-commit Cc
345        self_only: True to just email to yourself as a test
346        in_reply_to: If set we'll pass this to git as --in-reply-to.
347            Should be a message ID that this is in reply to.
348
349    Returns:
350        Git command that was/would be run
351
352    # For the duration of this doctest pretend that we ran patman with ./patman
353    >>> _old_argv0 = sys.argv[0]
354    >>> sys.argv[0] = './patman'
355
356    >>> alias = {}
357    >>> alias['fred'] = ['f.bloggs@napier.co.nz']
358    >>> alias['john'] = ['j.bloggs@napier.co.nz']
359    >>> alias['mary'] = ['m.poppins@cloud.net']
360    >>> alias['boys'] = ['fred', ' john']
361    >>> alias['all'] = ['fred ', 'john', '   mary   ']
362    >>> alias[os.getenv('USER')] = ['this-is-me@me.com']
363    >>> series = series.Series()
364    >>> series.to = ['fred']
365    >>> series.cc = ['mary']
366    >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \
367            False, alias)
368    'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \
369"m.poppins@cloud.net" --cc-cmd "./patman --cc-cmd cc-fname" cover p1 p2'
370    >>> EmailPatches(series, None, ['p1'], True, True, 'cc-fname', False, \
371            alias)
372    'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \
373"m.poppins@cloud.net" --cc-cmd "./patman --cc-cmd cc-fname" p1'
374    >>> series.cc = ['all']
375    >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \
376            True, alias)
377    'git send-email --annotate --to "this-is-me@me.com" --cc-cmd "./patman \
378--cc-cmd cc-fname" cover p1 p2'
379    >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \
380            False, alias)
381    'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \
382"f.bloggs@napier.co.nz" --cc "j.bloggs@napier.co.nz" --cc \
383"m.poppins@cloud.net" --cc-cmd "./patman --cc-cmd cc-fname" cover p1 p2'
384
385    # Restore argv[0] since we clobbered it.
386    >>> sys.argv[0] = _old_argv0
387    """
388    to = BuildEmailList(series.get('to'), '--to', alias, raise_on_error)
389    if not to:
390        print ("No recipient, please add something like this to a commit\n"
391            "Series-to: Fred Bloggs <f.blogs@napier.co.nz>")
392        return
393    cc = BuildEmailList(series.get('cc'), '--cc', alias, raise_on_error)
394    if self_only:
395        to = BuildEmailList([os.getenv('USER')], '--to', alias, raise_on_error)
396        cc = []
397    cmd = ['git', 'send-email', '--annotate']
398    if in_reply_to:
399        cmd.append('--in-reply-to="%s"' % in_reply_to)
400
401    cmd += to
402    cmd += cc
403    cmd += ['--cc-cmd', '"%s --cc-cmd %s"' % (sys.argv[0], cc_fname)]
404    if cover_fname:
405        cmd.append(cover_fname)
406    cmd += args
407    str = ' '.join(cmd)
408    if not dry_run:
409        os.system(str)
410    return str
411
412
413def LookupEmail(lookup_name, alias=None, raise_on_error=True, level=0):
414    """If an email address is an alias, look it up and return the full name
415
416    TODO: Why not just use git's own alias feature?
417
418    Args:
419        lookup_name: Alias or email address to look up
420        alias: Dictionary containing aliases (None to use settings default)
421        raise_on_error: True to raise an error when an alias fails to match,
422                False to just print a message.
423
424    Returns:
425        tuple:
426            list containing a list of email addresses
427
428    Raises:
429        OSError if a recursive alias reference was found
430        ValueError if an alias was not found
431
432    >>> alias = {}
433    >>> alias['fred'] = ['f.bloggs@napier.co.nz']
434    >>> alias['john'] = ['j.bloggs@napier.co.nz']
435    >>> alias['mary'] = ['m.poppins@cloud.net']
436    >>> alias['boys'] = ['fred', ' john', 'f.bloggs@napier.co.nz']
437    >>> alias['all'] = ['fred ', 'john', '   mary   ']
438    >>> alias['loop'] = ['other', 'john', '   mary   ']
439    >>> alias['other'] = ['loop', 'john', '   mary   ']
440    >>> LookupEmail('mary', alias)
441    ['m.poppins@cloud.net']
442    >>> LookupEmail('arthur.wellesley@howe.ro.uk', alias)
443    ['arthur.wellesley@howe.ro.uk']
444    >>> LookupEmail('boys', alias)
445    ['f.bloggs@napier.co.nz', 'j.bloggs@napier.co.nz']
446    >>> LookupEmail('all', alias)
447    ['f.bloggs@napier.co.nz', 'j.bloggs@napier.co.nz', 'm.poppins@cloud.net']
448    >>> LookupEmail('odd', alias)
449    Traceback (most recent call last):
450    ...
451    ValueError: Alias 'odd' not found
452    >>> LookupEmail('loop', alias)
453    Traceback (most recent call last):
454    ...
455    OSError: Recursive email alias at 'other'
456    >>> LookupEmail('odd', alias, raise_on_error=False)
457    \033[1;31mAlias 'odd' not found\033[0m
458    []
459    >>> # In this case the loop part will effectively be ignored.
460    >>> LookupEmail('loop', alias, raise_on_error=False)
461    \033[1;31mRecursive email alias at 'other'\033[0m
462    \033[1;31mRecursive email alias at 'john'\033[0m
463    \033[1;31mRecursive email alias at 'mary'\033[0m
464    ['j.bloggs@napier.co.nz', 'm.poppins@cloud.net']
465    """
466    if not alias:
467        alias = settings.alias
468    lookup_name = lookup_name.strip()
469    if '@' in lookup_name: # Perhaps a real email address
470        return [lookup_name]
471
472    lookup_name = lookup_name.lower()
473    col = terminal.Color()
474
475    out_list = []
476    if level > 10:
477        msg = "Recursive email alias at '%s'" % lookup_name
478        if raise_on_error:
479            raise OSError, msg
480        else:
481            print col.Color(col.RED, msg)
482            return out_list
483
484    if lookup_name:
485        if not lookup_name in alias:
486            msg = "Alias '%s' not found" % lookup_name
487            if raise_on_error:
488                raise ValueError, msg
489            else:
490                print col.Color(col.RED, msg)
491                return out_list
492        for item in alias[lookup_name]:
493            todo = LookupEmail(item, alias, raise_on_error, level + 1)
494            for new_item in todo:
495                if not new_item in out_list:
496                    out_list.append(new_item)
497
498    #print "No match for alias '%s'" % lookup_name
499    return out_list
500
501def GetTopLevel():
502    """Return name of top-level directory for this git repo.
503
504    Returns:
505        Full path to git top-level directory
506
507    This test makes sure that we are running tests in the right subdir
508
509    >>> os.path.realpath(os.path.dirname(__file__)) == \
510            os.path.join(GetTopLevel(), 'tools', 'patman')
511    True
512    """
513    return command.OutputOneLine('git', 'rev-parse', '--show-toplevel')
514
515def GetAliasFile():
516    """Gets the name of the git alias file.
517
518    Returns:
519        Filename of git alias file, or None if none
520    """
521    fname = command.OutputOneLine('git', 'config', 'sendemail.aliasesfile',
522            raise_on_error=False)
523    if fname:
524        fname = os.path.join(GetTopLevel(), fname.strip())
525    return fname
526
527def GetDefaultUserName():
528    """Gets the user.name from .gitconfig file.
529
530    Returns:
531        User name found in .gitconfig file, or None if none
532    """
533    uname = command.OutputOneLine('git', 'config', '--global', 'user.name')
534    return uname
535
536def GetDefaultUserEmail():
537    """Gets the user.email from the global .gitconfig file.
538
539    Returns:
540        User's email found in .gitconfig file, or None if none
541    """
542    uemail = command.OutputOneLine('git', 'config', '--global', 'user.email')
543    return uemail
544
545def Setup():
546    """Set up git utils, by reading the alias files."""
547    # Check for a git alias file also
548    alias_fname = GetAliasFile()
549    if alias_fname:
550        settings.ReadGitAliases(alias_fname)
551
552def GetHead():
553    """Get the hash of the current HEAD
554
555    Returns:
556        Hash of HEAD
557    """
558    return command.OutputOneLine('git', 'show', '-s', '--pretty=format:%H')
559
560if __name__ == "__main__":
561    import doctest
562
563    doctest.testmod()
564