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