xref: /openbmc/u-boot/tools/patman/gitutil.py (revision 29b103c7)
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):
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(list(set(series.get('cc')) - set(series.get('to'))),
396                        '--cc', alias, raise_on_error)
397    if self_only:
398        to = BuildEmailList([os.getenv('USER')], '--to', alias, raise_on_error)
399        cc = []
400    cmd = ['git', 'send-email', '--annotate']
401    if in_reply_to:
402        cmd.append('--in-reply-to="%s"' % in_reply_to)
403
404    cmd += to
405    cmd += cc
406    cmd += ['--cc-cmd', '"%s --cc-cmd %s"' % (sys.argv[0], cc_fname)]
407    if cover_fname:
408        cmd.append(cover_fname)
409    cmd += args
410    str = ' '.join(cmd)
411    if not dry_run:
412        os.system(str)
413    return str
414
415
416def LookupEmail(lookup_name, alias=None, raise_on_error=True, level=0):
417    """If an email address is an alias, look it up and return the full name
418
419    TODO: Why not just use git's own alias feature?
420
421    Args:
422        lookup_name: Alias or email address to look up
423        alias: Dictionary containing aliases (None to use settings default)
424        raise_on_error: True to raise an error when an alias fails to match,
425                False to just print a message.
426
427    Returns:
428        tuple:
429            list containing a list of email addresses
430
431    Raises:
432        OSError if a recursive alias reference was found
433        ValueError if an alias was not found
434
435    >>> alias = {}
436    >>> alias['fred'] = ['f.bloggs@napier.co.nz']
437    >>> alias['john'] = ['j.bloggs@napier.co.nz']
438    >>> alias['mary'] = ['m.poppins@cloud.net']
439    >>> alias['boys'] = ['fred', ' john', 'f.bloggs@napier.co.nz']
440    >>> alias['all'] = ['fred ', 'john', '   mary   ']
441    >>> alias['loop'] = ['other', 'john', '   mary   ']
442    >>> alias['other'] = ['loop', 'john', '   mary   ']
443    >>> LookupEmail('mary', alias)
444    ['m.poppins@cloud.net']
445    >>> LookupEmail('arthur.wellesley@howe.ro.uk', alias)
446    ['arthur.wellesley@howe.ro.uk']
447    >>> LookupEmail('boys', alias)
448    ['f.bloggs@napier.co.nz', 'j.bloggs@napier.co.nz']
449    >>> LookupEmail('all', alias)
450    ['f.bloggs@napier.co.nz', 'j.bloggs@napier.co.nz', 'm.poppins@cloud.net']
451    >>> LookupEmail('odd', alias)
452    Traceback (most recent call last):
453    ...
454    ValueError: Alias 'odd' not found
455    >>> LookupEmail('loop', alias)
456    Traceback (most recent call last):
457    ...
458    OSError: Recursive email alias at 'other'
459    >>> LookupEmail('odd', alias, raise_on_error=False)
460    Alias 'odd' not found
461    []
462    >>> # In this case the loop part will effectively be ignored.
463    >>> LookupEmail('loop', alias, raise_on_error=False)
464    Recursive email alias at 'other'
465    Recursive email alias at 'john'
466    Recursive email alias at 'mary'
467    ['j.bloggs@napier.co.nz', 'm.poppins@cloud.net']
468    """
469    if not alias:
470        alias = settings.alias
471    lookup_name = lookup_name.strip()
472    if '@' in lookup_name: # Perhaps a real email address
473        return [lookup_name]
474
475    lookup_name = lookup_name.lower()
476    col = terminal.Color()
477
478    out_list = []
479    if level > 10:
480        msg = "Recursive email alias at '%s'" % lookup_name
481        if raise_on_error:
482            raise OSError, msg
483        else:
484            print col.Color(col.RED, msg)
485            return out_list
486
487    if lookup_name:
488        if not lookup_name in alias:
489            msg = "Alias '%s' not found" % lookup_name
490            if raise_on_error:
491                raise ValueError, msg
492            else:
493                print col.Color(col.RED, msg)
494                return out_list
495        for item in alias[lookup_name]:
496            todo = LookupEmail(item, alias, raise_on_error, level + 1)
497            for new_item in todo:
498                if not new_item in out_list:
499                    out_list.append(new_item)
500
501    #print "No match for alias '%s'" % lookup_name
502    return out_list
503
504def GetTopLevel():
505    """Return name of top-level directory for this git repo.
506
507    Returns:
508        Full path to git top-level directory
509
510    This test makes sure that we are running tests in the right subdir
511
512    >>> os.path.realpath(os.path.dirname(__file__)) == \
513            os.path.join(GetTopLevel(), 'tools', 'patman')
514    True
515    """
516    return command.OutputOneLine('git', 'rev-parse', '--show-toplevel')
517
518def GetAliasFile():
519    """Gets the name of the git alias file.
520
521    Returns:
522        Filename of git alias file, or None if none
523    """
524    fname = command.OutputOneLine('git', 'config', 'sendemail.aliasesfile',
525            raise_on_error=False)
526    if fname:
527        fname = os.path.join(GetTopLevel(), fname.strip())
528    return fname
529
530def GetDefaultUserName():
531    """Gets the user.name from .gitconfig file.
532
533    Returns:
534        User name found in .gitconfig file, or None if none
535    """
536    uname = command.OutputOneLine('git', 'config', '--global', 'user.name')
537    return uname
538
539def GetDefaultUserEmail():
540    """Gets the user.email from the global .gitconfig file.
541
542    Returns:
543        User's email found in .gitconfig file, or None if none
544    """
545    uemail = command.OutputOneLine('git', 'config', '--global', 'user.email')
546    return uemail
547
548def GetDefaultSubjectPrefix():
549    """Gets the format.subjectprefix from local .git/config file.
550
551    Returns:
552        Subject prefix found in local .git/config file, or None if none
553    """
554    sub_prefix = command.OutputOneLine('git', 'config', 'format.subjectprefix',
555                 raise_on_error=False)
556
557    return sub_prefix
558
559def Setup():
560    """Set up git utils, by reading the alias files."""
561    # Check for a git alias file also
562    global use_no_decorate
563
564    alias_fname = GetAliasFile()
565    if alias_fname:
566        settings.ReadGitAliases(alias_fname)
567    cmd = LogCmd(None, count=0)
568    use_no_decorate = (command.RunPipe([cmd], raise_on_error=False)
569                       .return_code == 0)
570
571def GetHead():
572    """Get the hash of the current HEAD
573
574    Returns:
575        Hash of HEAD
576    """
577    return command.OutputOneLine('git', 'show', '-s', '--pretty=format:%H')
578
579if __name__ == "__main__":
580    import doctest
581
582    doctest.testmod()
583