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