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