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