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