xref: /openbmc/u-boot/tools/patman/gitutil.py (revision a1655bb2)
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
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):
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
346    Returns:
347        Git command that was/would be run
348
349    # For the duration of this doctest pretend that we ran patman with ./patman
350    >>> _old_argv0 = sys.argv[0]
351    >>> sys.argv[0] = './patman'
352
353    >>> alias = {}
354    >>> alias['fred'] = ['f.bloggs@napier.co.nz']
355    >>> alias['john'] = ['j.bloggs@napier.co.nz']
356    >>> alias['mary'] = ['m.poppins@cloud.net']
357    >>> alias['boys'] = ['fred', ' john']
358    >>> alias['all'] = ['fred ', 'john', '   mary   ']
359    >>> alias[os.getenv('USER')] = ['this-is-me@me.com']
360    >>> series = series.Series()
361    >>> series.to = ['fred']
362    >>> series.cc = ['mary']
363    >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \
364            False, alias)
365    'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \
366"m.poppins@cloud.net" --cc-cmd "./patman --cc-cmd cc-fname" cover p1 p2'
367    >>> EmailPatches(series, None, ['p1'], True, True, 'cc-fname', False, \
368            alias)
369    'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \
370"m.poppins@cloud.net" --cc-cmd "./patman --cc-cmd cc-fname" p1'
371    >>> series.cc = ['all']
372    >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \
373            True, alias)
374    'git send-email --annotate --to "this-is-me@me.com" --cc-cmd "./patman \
375--cc-cmd cc-fname" cover p1 p2'
376    >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \
377            False, alias)
378    'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \
379"f.bloggs@napier.co.nz" --cc "j.bloggs@napier.co.nz" --cc \
380"m.poppins@cloud.net" --cc-cmd "./patman --cc-cmd cc-fname" cover p1 p2'
381
382    # Restore argv[0] since we clobbered it.
383    >>> sys.argv[0] = _old_argv0
384    """
385    to = BuildEmailList(series.get('to'), '--to', alias, raise_on_error)
386    if not to:
387        git_config_to = command.Output('git', 'config', 'sendemail.to')
388        if not git_config_to:
389            print ("No recipient.\n"
390                   "Please add something like this to a commit\n"
391                   "Series-to: Fred Bloggs <f.blogs@napier.co.nz>\n"
392                   "Or do something like this\n"
393                   "git config sendemail.to u-boot@lists.denx.de")
394            return
395    cc = BuildEmailList(series.get('cc'), '--cc', alias, raise_on_error)
396    if self_only:
397        to = BuildEmailList([os.getenv('USER')], '--to', alias, raise_on_error)
398        cc = []
399    cmd = ['git', 'send-email', '--annotate']
400    if in_reply_to:
401        cmd.append('--in-reply-to="%s"' % in_reply_to)
402
403    cmd += to
404    cmd += cc
405    cmd += ['--cc-cmd', '"%s --cc-cmd %s"' % (sys.argv[0], cc_fname)]
406    if cover_fname:
407        cmd.append(cover_fname)
408    cmd += args
409    str = ' '.join(cmd)
410    if not dry_run:
411        os.system(str)
412    return str
413
414
415def LookupEmail(lookup_name, alias=None, raise_on_error=True, level=0):
416    """If an email address is an alias, look it up and return the full name
417
418    TODO: Why not just use git's own alias feature?
419
420    Args:
421        lookup_name: Alias or email address to look up
422        alias: Dictionary containing aliases (None to use settings default)
423        raise_on_error: True to raise an error when an alias fails to match,
424                False to just print a message.
425
426    Returns:
427        tuple:
428            list containing a list of email addresses
429
430    Raises:
431        OSError if a recursive alias reference was found
432        ValueError if an alias was not found
433
434    >>> alias = {}
435    >>> alias['fred'] = ['f.bloggs@napier.co.nz']
436    >>> alias['john'] = ['j.bloggs@napier.co.nz']
437    >>> alias['mary'] = ['m.poppins@cloud.net']
438    >>> alias['boys'] = ['fred', ' john', 'f.bloggs@napier.co.nz']
439    >>> alias['all'] = ['fred ', 'john', '   mary   ']
440    >>> alias['loop'] = ['other', 'john', '   mary   ']
441    >>> alias['other'] = ['loop', 'john', '   mary   ']
442    >>> LookupEmail('mary', alias)
443    ['m.poppins@cloud.net']
444    >>> LookupEmail('arthur.wellesley@howe.ro.uk', alias)
445    ['arthur.wellesley@howe.ro.uk']
446    >>> LookupEmail('boys', alias)
447    ['f.bloggs@napier.co.nz', 'j.bloggs@napier.co.nz']
448    >>> LookupEmail('all', alias)
449    ['f.bloggs@napier.co.nz', 'j.bloggs@napier.co.nz', 'm.poppins@cloud.net']
450    >>> LookupEmail('odd', alias)
451    Traceback (most recent call last):
452    ...
453    ValueError: Alias 'odd' not found
454    >>> LookupEmail('loop', alias)
455    Traceback (most recent call last):
456    ...
457    OSError: Recursive email alias at 'other'
458    >>> LookupEmail('odd', alias, raise_on_error=False)
459    Alias 'odd' not found
460    []
461    >>> # In this case the loop part will effectively be ignored.
462    >>> LookupEmail('loop', alias, raise_on_error=False)
463    Recursive email alias at 'other'
464    Recursive email alias at 'john'
465    Recursive email alias at 'mary'
466    ['j.bloggs@napier.co.nz', 'm.poppins@cloud.net']
467    """
468    if not alias:
469        alias = settings.alias
470    lookup_name = lookup_name.strip()
471    if '@' in lookup_name: # Perhaps a real email address
472        return [lookup_name]
473
474    lookup_name = lookup_name.lower()
475    col = terminal.Color()
476
477    out_list = []
478    if level > 10:
479        msg = "Recursive email alias at '%s'" % lookup_name
480        if raise_on_error:
481            raise OSError, msg
482        else:
483            print col.Color(col.RED, msg)
484            return out_list
485
486    if lookup_name:
487        if not lookup_name in alias:
488            msg = "Alias '%s' not found" % lookup_name
489            if raise_on_error:
490                raise ValueError, msg
491            else:
492                print col.Color(col.RED, msg)
493                return out_list
494        for item in alias[lookup_name]:
495            todo = LookupEmail(item, alias, raise_on_error, level + 1)
496            for new_item in todo:
497                if not new_item in out_list:
498                    out_list.append(new_item)
499
500    #print "No match for alias '%s'" % lookup_name
501    return out_list
502
503def GetTopLevel():
504    """Return name of top-level directory for this git repo.
505
506    Returns:
507        Full path to git top-level directory
508
509    This test makes sure that we are running tests in the right subdir
510
511    >>> os.path.realpath(os.path.dirname(__file__)) == \
512            os.path.join(GetTopLevel(), 'tools', 'patman')
513    True
514    """
515    return command.OutputOneLine('git', 'rev-parse', '--show-toplevel')
516
517def GetAliasFile():
518    """Gets the name of the git alias file.
519
520    Returns:
521        Filename of git alias file, or None if none
522    """
523    fname = command.OutputOneLine('git', 'config', 'sendemail.aliasesfile',
524            raise_on_error=False)
525    if fname:
526        fname = os.path.join(GetTopLevel(), fname.strip())
527    return fname
528
529def GetDefaultUserName():
530    """Gets the user.name from .gitconfig file.
531
532    Returns:
533        User name found in .gitconfig file, or None if none
534    """
535    uname = command.OutputOneLine('git', 'config', '--global', 'user.name')
536    return uname
537
538def GetDefaultUserEmail():
539    """Gets the user.email from the global .gitconfig file.
540
541    Returns:
542        User's email found in .gitconfig file, or None if none
543    """
544    uemail = command.OutputOneLine('git', 'config', '--global', 'user.email')
545    return uemail
546
547def Setup():
548    """Set up git utils, by reading the alias files."""
549    # Check for a git alias file also
550    global use_no_decorate
551
552    alias_fname = GetAliasFile()
553    if alias_fname:
554        settings.ReadGitAliases(alias_fname)
555    cmd = LogCmd(None, count=0)
556    use_no_decorate = (command.RunPipe([cmd], raise_on_error=False)
557                       .return_code == 0)
558
559def GetHead():
560    """Get the hash of the current HEAD
561
562    Returns:
563        Hash of HEAD
564    """
565    return command.OutputOneLine('git', 'show', '-s', '--pretty=format:%H')
566
567if __name__ == "__main__":
568    import doctest
569
570    doctest.testmod()
571