xref: /openbmc/u-boot/tools/patman/gitutil.py (revision ffe16911)
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
236    # Checkout the required start point
237    cmd = ['git', 'checkout', 'HEAD~%d' % start_point]
238    pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE,
239            stderr=subprocess.PIPE)
240    stdout, stderr = pipe.communicate()
241    if pipe.returncode:
242        str = 'Could not move to commit before patch series'
243        print col.Color(col.RED, str)
244        print stdout, stderr
245        return False
246
247    # Apply all the patches
248    for fname in args:
249        ok, stdout = ApplyPatch(verbose, fname)
250        if not ok:
251            print col.Color(col.RED, 'git am returned errors for %s: will '
252                    'skip this patch' % fname)
253            if verbose:
254                print stdout
255            error_count += 1
256            cmd = ['git', 'am', '--skip']
257            pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE)
258            stdout, stderr = pipe.communicate()
259            if pipe.returncode != 0:
260                print col.Color(col.RED, 'Unable to skip patch! Aborting...')
261                print stdout
262                break
263
264    # Return to our previous position
265    cmd = ['git', 'checkout', old_head]
266    pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
267    stdout, stderr = pipe.communicate()
268    if pipe.returncode:
269        print col.Color(col.RED, 'Could not move back to head commit')
270        print stdout, stderr
271    return error_count == 0
272
273def BuildEmailList(in_list, tag=None, alias=None, raise_on_error=True):
274    """Build a list of email addresses based on an input list.
275
276    Takes a list of email addresses and aliases, and turns this into a list
277    of only email address, by resolving any aliases that are present.
278
279    If the tag is given, then each email address is prepended with this
280    tag and a space. If the tag starts with a minus sign (indicating a
281    command line parameter) then the email address is quoted.
282
283    Args:
284        in_list:        List of aliases/email addresses
285        tag:            Text to put before each address
286        alias:          Alias dictionary
287        raise_on_error: True to raise an error when an alias fails to match,
288                False to just print a message.
289
290    Returns:
291        List of email addresses
292
293    >>> alias = {}
294    >>> alias['fred'] = ['f.bloggs@napier.co.nz']
295    >>> alias['john'] = ['j.bloggs@napier.co.nz']
296    >>> alias['mary'] = ['Mary Poppins <m.poppins@cloud.net>']
297    >>> alias['boys'] = ['fred', ' john']
298    >>> alias['all'] = ['fred ', 'john', '   mary   ']
299    >>> BuildEmailList(['john', 'mary'], None, alias)
300    ['j.bloggs@napier.co.nz', 'Mary Poppins <m.poppins@cloud.net>']
301    >>> BuildEmailList(['john', 'mary'], '--to', alias)
302    ['--to "j.bloggs@napier.co.nz"', \
303'--to "Mary Poppins <m.poppins@cloud.net>"']
304    >>> BuildEmailList(['john', 'mary'], 'Cc', alias)
305    ['Cc j.bloggs@napier.co.nz', 'Cc Mary Poppins <m.poppins@cloud.net>']
306    """
307    quote = '"' if tag and tag[0] == '-' else ''
308    raw = []
309    for item in in_list:
310        raw += LookupEmail(item, alias, raise_on_error=raise_on_error)
311    result = []
312    for item in raw:
313        if not item in result:
314            result.append(item)
315    if tag:
316        return ['%s %s%s%s' % (tag, quote, email, quote) for email in result]
317    return result
318
319def EmailPatches(series, cover_fname, args, dry_run, raise_on_error, cc_fname,
320        self_only=False, alias=None, in_reply_to=None):
321    """Email a patch series.
322
323    Args:
324        series: Series object containing destination info
325        cover_fname: filename of cover letter
326        args: list of filenames of patch files
327        dry_run: Just return the command that would be run
328        raise_on_error: True to raise an error when an alias fails to match,
329                False to just print a message.
330        cc_fname: Filename of Cc file for per-commit Cc
331        self_only: True to just email to yourself as a test
332        in_reply_to: If set we'll pass this to git as --in-reply-to.
333            Should be a message ID that this is in reply to.
334
335    Returns:
336        Git command that was/would be run
337
338    # For the duration of this doctest pretend that we ran patman with ./patman
339    >>> _old_argv0 = sys.argv[0]
340    >>> sys.argv[0] = './patman'
341
342    >>> alias = {}
343    >>> alias['fred'] = ['f.bloggs@napier.co.nz']
344    >>> alias['john'] = ['j.bloggs@napier.co.nz']
345    >>> alias['mary'] = ['m.poppins@cloud.net']
346    >>> alias['boys'] = ['fred', ' john']
347    >>> alias['all'] = ['fred ', 'john', '   mary   ']
348    >>> alias[os.getenv('USER')] = ['this-is-me@me.com']
349    >>> series = series.Series()
350    >>> series.to = ['fred']
351    >>> series.cc = ['mary']
352    >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \
353            False, alias)
354    'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \
355"m.poppins@cloud.net" --cc-cmd "./patman --cc-cmd cc-fname" cover p1 p2'
356    >>> EmailPatches(series, None, ['p1'], True, True, 'cc-fname', False, \
357            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" p1'
360    >>> series.cc = ['all']
361    >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \
362            True, alias)
363    'git send-email --annotate --to "this-is-me@me.com" --cc-cmd "./patman \
364--cc-cmd cc-fname" cover p1 p2'
365    >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \
366            False, alias)
367    'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \
368"f.bloggs@napier.co.nz" --cc "j.bloggs@napier.co.nz" --cc \
369"m.poppins@cloud.net" --cc-cmd "./patman --cc-cmd cc-fname" cover p1 p2'
370
371    # Restore argv[0] since we clobbered it.
372    >>> sys.argv[0] = _old_argv0
373    """
374    to = BuildEmailList(series.get('to'), '--to', alias, raise_on_error)
375    if not to:
376        print ("No recipient, please add something like this to a commit\n"
377            "Series-to: Fred Bloggs <f.blogs@napier.co.nz>")
378        return
379    cc = BuildEmailList(series.get('cc'), '--cc', alias, raise_on_error)
380    if self_only:
381        to = BuildEmailList([os.getenv('USER')], '--to', alias, raise_on_error)
382        cc = []
383    cmd = ['git', 'send-email', '--annotate']
384    if in_reply_to:
385        cmd.append('--in-reply-to="%s"' % in_reply_to)
386
387    cmd += to
388    cmd += cc
389    cmd += ['--cc-cmd', '"%s --cc-cmd %s"' % (sys.argv[0], cc_fname)]
390    if cover_fname:
391        cmd.append(cover_fname)
392    cmd += args
393    str = ' '.join(cmd)
394    if not dry_run:
395        os.system(str)
396    return str
397
398
399def LookupEmail(lookup_name, alias=None, raise_on_error=True, level=0):
400    """If an email address is an alias, look it up and return the full name
401
402    TODO: Why not just use git's own alias feature?
403
404    Args:
405        lookup_name: Alias or email address to look up
406        alias: Dictionary containing aliases (None to use settings default)
407        raise_on_error: True to raise an error when an alias fails to match,
408                False to just print a message.
409
410    Returns:
411        tuple:
412            list containing a list of email addresses
413
414    Raises:
415        OSError if a recursive alias reference was found
416        ValueError if an alias was not found
417
418    >>> alias = {}
419    >>> alias['fred'] = ['f.bloggs@napier.co.nz']
420    >>> alias['john'] = ['j.bloggs@napier.co.nz']
421    >>> alias['mary'] = ['m.poppins@cloud.net']
422    >>> alias['boys'] = ['fred', ' john', 'f.bloggs@napier.co.nz']
423    >>> alias['all'] = ['fred ', 'john', '   mary   ']
424    >>> alias['loop'] = ['other', 'john', '   mary   ']
425    >>> alias['other'] = ['loop', 'john', '   mary   ']
426    >>> LookupEmail('mary', alias)
427    ['m.poppins@cloud.net']
428    >>> LookupEmail('arthur.wellesley@howe.ro.uk', alias)
429    ['arthur.wellesley@howe.ro.uk']
430    >>> LookupEmail('boys', alias)
431    ['f.bloggs@napier.co.nz', 'j.bloggs@napier.co.nz']
432    >>> LookupEmail('all', alias)
433    ['f.bloggs@napier.co.nz', 'j.bloggs@napier.co.nz', 'm.poppins@cloud.net']
434    >>> LookupEmail('odd', alias)
435    Traceback (most recent call last):
436    ...
437    ValueError: Alias 'odd' not found
438    >>> LookupEmail('loop', alias)
439    Traceback (most recent call last):
440    ...
441    OSError: Recursive email alias at 'other'
442    >>> LookupEmail('odd', alias, raise_on_error=False)
443    \033[1;31mAlias 'odd' not found\033[0m
444    []
445    >>> # In this case the loop part will effectively be ignored.
446    >>> LookupEmail('loop', alias, raise_on_error=False)
447    \033[1;31mRecursive email alias at 'other'\033[0m
448    \033[1;31mRecursive email alias at 'john'\033[0m
449    \033[1;31mRecursive email alias at 'mary'\033[0m
450    ['j.bloggs@napier.co.nz', 'm.poppins@cloud.net']
451    """
452    if not alias:
453        alias = settings.alias
454    lookup_name = lookup_name.strip()
455    if '@' in lookup_name: # Perhaps a real email address
456        return [lookup_name]
457
458    lookup_name = lookup_name.lower()
459    col = terminal.Color()
460
461    out_list = []
462    if level > 10:
463        msg = "Recursive email alias at '%s'" % lookup_name
464        if raise_on_error:
465            raise OSError, msg
466        else:
467            print col.Color(col.RED, msg)
468            return out_list
469
470    if lookup_name:
471        if not lookup_name in alias:
472            msg = "Alias '%s' not found" % lookup_name
473            if raise_on_error:
474                raise ValueError, msg
475            else:
476                print col.Color(col.RED, msg)
477                return out_list
478        for item in alias[lookup_name]:
479            todo = LookupEmail(item, alias, raise_on_error, level + 1)
480            for new_item in todo:
481                if not new_item in out_list:
482                    out_list.append(new_item)
483
484    #print "No match for alias '%s'" % lookup_name
485    return out_list
486
487def GetTopLevel():
488    """Return name of top-level directory for this git repo.
489
490    Returns:
491        Full path to git top-level directory
492
493    This test makes sure that we are running tests in the right subdir
494
495    >>> os.path.realpath(os.path.dirname(__file__)) == \
496            os.path.join(GetTopLevel(), 'tools', 'patman')
497    True
498    """
499    return command.OutputOneLine('git', 'rev-parse', '--show-toplevel')
500
501def GetAliasFile():
502    """Gets the name of the git alias file.
503
504    Returns:
505        Filename of git alias file, or None if none
506    """
507    fname = command.OutputOneLine('git', 'config', 'sendemail.aliasesfile',
508            raise_on_error=False)
509    if fname:
510        fname = os.path.join(GetTopLevel(), fname.strip())
511    return fname
512
513def GetDefaultUserName():
514    """Gets the user.name from .gitconfig file.
515
516    Returns:
517        User name found in .gitconfig file, or None if none
518    """
519    uname = command.OutputOneLine('git', 'config', '--global', 'user.name')
520    return uname
521
522def GetDefaultUserEmail():
523    """Gets the user.email from the global .gitconfig file.
524
525    Returns:
526        User's email found in .gitconfig file, or None if none
527    """
528    uemail = command.OutputOneLine('git', 'config', '--global', 'user.email')
529    return uemail
530
531def Setup():
532    """Set up git utils, by reading the alias files."""
533    # Check for a git alias file also
534    alias_fname = GetAliasFile()
535    if alias_fname:
536        settings.ReadGitAliases(alias_fname)
537
538def GetHead():
539    """Get the hash of the current HEAD
540
541    Returns:
542        Hash of HEAD
543    """
544    return command.OutputOneLine('git', 'show', '-s', '--pretty=format:%H')
545
546if __name__ == "__main__":
547    import doctest
548
549    doctest.testmod()
550