• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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
15import tools
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 repository (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
48    # Add this in case we have a branch with the same name as a directory.
49    # This avoids messages like this, for example:
50    #   fatal: ambiguous argument 'test': both revision and filename
51    cmd.append('--')
52    return cmd
53
54def CountCommitsToBranch():
55    """Returns number of commits between HEAD and the tracking branch.
56
57    This looks back to the tracking branch and works out the number of commits
58    since then.
59
60    Return:
61        Number of patches that exist on top of the branch
62    """
63    pipe = [LogCmd('@{upstream}..', oneline=True),
64            ['wc', '-l']]
65    stdout = command.RunPipe(pipe, capture=True, oneline=True).stdout
66    patch_count = int(stdout)
67    return patch_count
68
69def NameRevision(commit_hash):
70    """Gets the revision name for a commit
71
72    Args:
73        commit_hash: Commit hash to look up
74
75    Return:
76        Name of revision, if any, else None
77    """
78    pipe = ['git', 'name-rev', commit_hash]
79    stdout = command.RunPipe([pipe], capture=True, oneline=True).stdout
80
81    # We expect a commit, a space, then a revision name
82    name = stdout.split(' ')[1].strip()
83    return name
84
85def GuessUpstream(git_dir, branch):
86    """Tries to guess the upstream for a branch
87
88    This lists out top commits on a branch and tries to find a suitable
89    upstream. It does this by looking for the first commit where
90    'git name-rev' returns a plain branch name, with no ! or ^ modifiers.
91
92    Args:
93        git_dir: Git directory containing repo
94        branch: Name of branch
95
96    Returns:
97        Tuple:
98            Name of upstream branch (e.g. 'upstream/master') or None if none
99            Warning/error message, or None if none
100    """
101    pipe = [LogCmd(branch, git_dir=git_dir, oneline=True, count=100)]
102    result = command.RunPipe(pipe, capture=True, capture_stderr=True,
103                             raise_on_error=False)
104    if result.return_code:
105        return None, "Branch '%s' not found" % branch
106    for line in result.stdout.splitlines()[1:]:
107        commit_hash = line.split(' ')[0]
108        name = NameRevision(commit_hash)
109        if '~' not in name and '^' not in name:
110            if name.startswith('remotes/'):
111                name = name[8:]
112            return name, "Guessing upstream as '%s'" % name
113    return None, "Cannot find a suitable upstream for branch '%s'" % branch
114
115def GetUpstream(git_dir, branch):
116    """Returns the name of the upstream for a branch
117
118    Args:
119        git_dir: Git directory containing repo
120        branch: Name of branch
121
122    Returns:
123        Tuple:
124            Name of upstream branch (e.g. 'upstream/master') or None if none
125            Warning/error message, or None if none
126    """
127    try:
128        remote = command.OutputOneLine('git', '--git-dir', git_dir, 'config',
129                                       'branch.%s.remote' % branch)
130        merge = command.OutputOneLine('git', '--git-dir', git_dir, 'config',
131                                      'branch.%s.merge' % branch)
132    except:
133        upstream, msg = GuessUpstream(git_dir, branch)
134        return upstream, msg
135
136    if remote == '.':
137        return merge, None
138    elif remote and merge:
139        leaf = merge.split('/')[-1]
140        return '%s/%s' % (remote, leaf), None
141    else:
142        raise ValueError("Cannot determine upstream branch for branch "
143                "'%s' remote='%s', merge='%s'" % (branch, remote, merge))
144
145
146def GetRangeInBranch(git_dir, branch, include_upstream=False):
147    """Returns an expression for the commits in the given branch.
148
149    Args:
150        git_dir: Directory containing git repo
151        branch: Name of branch
152    Return:
153        Expression in the form 'upstream..branch' which can be used to
154        access the commits. If the branch does not exist, returns None.
155    """
156    upstream, msg = GetUpstream(git_dir, branch)
157    if not upstream:
158        return None, msg
159    rstr = '%s%s..%s' % (upstream, '~' if include_upstream else '', branch)
160    return rstr, msg
161
162def CountCommitsInRange(git_dir, range_expr):
163    """Returns the number of commits in the given range.
164
165    Args:
166        git_dir: Directory containing git repo
167        range_expr: Range to check
168    Return:
169        Number of patches that exist in the supplied range or None if none
170        were found
171    """
172    pipe = [LogCmd(range_expr, git_dir=git_dir, oneline=True)]
173    result = command.RunPipe(pipe, capture=True, capture_stderr=True,
174                             raise_on_error=False)
175    if result.return_code:
176        return None, "Range '%s' not found or is invalid" % range_expr
177    patch_count = len(result.stdout.splitlines())
178    return patch_count, None
179
180def CountCommitsInBranch(git_dir, branch, include_upstream=False):
181    """Returns the number of commits in the given branch.
182
183    Args:
184        git_dir: Directory containing git repo
185        branch: Name of branch
186    Return:
187        Number of patches that exist on top of the branch, or None if the
188        branch does not exist.
189    """
190    range_expr, msg = GetRangeInBranch(git_dir, branch, include_upstream)
191    if not range_expr:
192        return None, msg
193    return CountCommitsInRange(git_dir, range_expr)
194
195def CountCommits(commit_range):
196    """Returns the number of commits in the given range.
197
198    Args:
199        commit_range: Range of commits to count (e.g. 'HEAD..base')
200    Return:
201        Number of patches that exist on top of the branch
202    """
203    pipe = [LogCmd(commit_range, oneline=True),
204            ['wc', '-l']]
205    stdout = command.RunPipe(pipe, capture=True, oneline=True).stdout
206    patch_count = int(stdout)
207    return patch_count
208
209def Checkout(commit_hash, git_dir=None, work_tree=None, force=False):
210    """Checkout the selected commit for this build
211
212    Args:
213        commit_hash: Commit hash to check out
214    """
215    pipe = ['git']
216    if git_dir:
217        pipe.extend(['--git-dir', git_dir])
218    if work_tree:
219        pipe.extend(['--work-tree', work_tree])
220    pipe.append('checkout')
221    if force:
222        pipe.append('-f')
223    pipe.append(commit_hash)
224    result = command.RunPipe([pipe], capture=True, raise_on_error=False,
225                             capture_stderr=True)
226    if result.return_code != 0:
227        raise OSError('git checkout (%s): %s' % (pipe, result.stderr))
228
229def Clone(git_dir, output_dir):
230    """Checkout the selected commit for this build
231
232    Args:
233        commit_hash: Commit hash to check out
234    """
235    pipe = ['git', 'clone', git_dir, '.']
236    result = command.RunPipe([pipe], capture=True, cwd=output_dir,
237                             capture_stderr=True)
238    if result.return_code != 0:
239        raise OSError('git clone: %s' % result.stderr)
240
241def Fetch(git_dir=None, work_tree=None):
242    """Fetch from the origin repo
243
244    Args:
245        commit_hash: Commit hash to check out
246    """
247    pipe = ['git']
248    if git_dir:
249        pipe.extend(['--git-dir', git_dir])
250    if work_tree:
251        pipe.extend(['--work-tree', work_tree])
252    pipe.append('fetch')
253    result = command.RunPipe([pipe], capture=True, capture_stderr=True)
254    if result.return_code != 0:
255        raise OSError('git fetch: %s' % result.stderr)
256
257def CreatePatches(start, count, series):
258    """Create a series of patches from the top of the current branch.
259
260    The patch files are written to the current directory using
261    git format-patch.
262
263    Args:
264        start: Commit to start from: 0=HEAD, 1=next one, etc.
265        count: number of commits to include
266    Return:
267        Filename of cover letter
268        List of filenames of patch files
269    """
270    if series.get('version'):
271        version = '%s ' % series['version']
272    cmd = ['git', 'format-patch', '-M', '--signoff']
273    if series.get('cover'):
274        cmd.append('--cover-letter')
275    prefix = series.GetPatchPrefix()
276    if prefix:
277        cmd += ['--subject-prefix=%s' % prefix]
278    cmd += ['HEAD~%d..HEAD~%d' % (start + count, start)]
279
280    stdout = command.RunList(cmd)
281    files = stdout.splitlines()
282
283    # We have an extra file if there is a cover letter
284    if series.get('cover'):
285       return files[0], files[1:]
286    else:
287       return None, files
288
289def BuildEmailList(in_list, tag=None, alias=None, raise_on_error=True):
290    """Build a list of email addresses based on an input list.
291
292    Takes a list of email addresses and aliases, and turns this into a list
293    of only email address, by resolving any aliases that are present.
294
295    If the tag is given, then each email address is prepended with this
296    tag and a space. If the tag starts with a minus sign (indicating a
297    command line parameter) then the email address is quoted.
298
299    Args:
300        in_list:        List of aliases/email addresses
301        tag:            Text to put before each address
302        alias:          Alias dictionary
303        raise_on_error: True to raise an error when an alias fails to match,
304                False to just print a message.
305
306    Returns:
307        List of email addresses
308
309    >>> alias = {}
310    >>> alias['fred'] = ['f.bloggs@napier.co.nz']
311    >>> alias['john'] = ['j.bloggs@napier.co.nz']
312    >>> alias['mary'] = ['Mary Poppins <m.poppins@cloud.net>']
313    >>> alias['boys'] = ['fred', ' john']
314    >>> alias['all'] = ['fred ', 'john', '   mary   ']
315    >>> BuildEmailList(['john', 'mary'], None, alias)
316    ['j.bloggs@napier.co.nz', 'Mary Poppins <m.poppins@cloud.net>']
317    >>> BuildEmailList(['john', 'mary'], '--to', alias)
318    ['--to "j.bloggs@napier.co.nz"', \
319'--to "Mary Poppins <m.poppins@cloud.net>"']
320    >>> BuildEmailList(['john', 'mary'], 'Cc', alias)
321    ['Cc j.bloggs@napier.co.nz', 'Cc Mary Poppins <m.poppins@cloud.net>']
322    """
323    quote = '"' if tag and tag[0] == '-' else ''
324    raw = []
325    for item in in_list:
326        raw += LookupEmail(item, alias, raise_on_error=raise_on_error)
327    result = []
328    for item in raw:
329        item = tools.FromUnicode(item)
330        if not item in result:
331            result.append(item)
332    if tag:
333        return ['%s %s%s%s' % (tag, quote, email, quote) for email in result]
334    return result
335
336def EmailPatches(series, cover_fname, args, dry_run, raise_on_error, cc_fname,
337        self_only=False, alias=None, in_reply_to=None, thread=False,
338        smtp_server=None):
339    """Email a patch series.
340
341    Args:
342        series: Series object containing destination info
343        cover_fname: filename of cover letter
344        args: list of filenames of patch files
345        dry_run: Just return the command that would be run
346        raise_on_error: True to raise an error when an alias fails to match,
347                False to just print a message.
348        cc_fname: Filename of Cc file for per-commit Cc
349        self_only: True to just email to yourself as a test
350        in_reply_to: If set we'll pass this to git as --in-reply-to.
351            Should be a message ID that this is in reply to.
352        thread: True to add --thread to git send-email (make
353            all patches reply to cover-letter or first patch in series)
354        smtp_server: SMTP server to use to send patches
355
356    Returns:
357        Git command that was/would be run
358
359    # For the duration of this doctest pretend that we ran patman with ./patman
360    >>> _old_argv0 = sys.argv[0]
361    >>> sys.argv[0] = './patman'
362
363    >>> alias = {}
364    >>> alias['fred'] = ['f.bloggs@napier.co.nz']
365    >>> alias['john'] = ['j.bloggs@napier.co.nz']
366    >>> alias['mary'] = ['m.poppins@cloud.net']
367    >>> alias['boys'] = ['fred', ' john']
368    >>> alias['all'] = ['fred ', 'john', '   mary   ']
369    >>> alias[os.getenv('USER')] = ['this-is-me@me.com']
370    >>> series = series.Series()
371    >>> series.to = ['fred']
372    >>> series.cc = ['mary']
373    >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \
374            False, alias)
375    'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \
376"m.poppins@cloud.net" --cc-cmd "./patman --cc-cmd cc-fname" cover p1 p2'
377    >>> EmailPatches(series, None, ['p1'], True, True, 'cc-fname', False, \
378            alias)
379    'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \
380"m.poppins@cloud.net" --cc-cmd "./patman --cc-cmd cc-fname" p1'
381    >>> series.cc = ['all']
382    >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \
383            True, alias)
384    'git send-email --annotate --to "this-is-me@me.com" --cc-cmd "./patman \
385--cc-cmd cc-fname" cover p1 p2'
386    >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \
387            False, alias)
388    'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \
389"f.bloggs@napier.co.nz" --cc "j.bloggs@napier.co.nz" --cc \
390"m.poppins@cloud.net" --cc-cmd "./patman --cc-cmd cc-fname" cover p1 p2'
391
392    # Restore argv[0] since we clobbered it.
393    >>> sys.argv[0] = _old_argv0
394    """
395    to = BuildEmailList(series.get('to'), '--to', alias, raise_on_error)
396    if not to:
397        git_config_to = command.Output('git', 'config', 'sendemail.to',
398                                       raise_on_error=False)
399        if not git_config_to:
400            print("No recipient.\n"
401                  "Please add something like this to a commit\n"
402                  "Series-to: Fred Bloggs <f.blogs@napier.co.nz>\n"
403                  "Or do something like this\n"
404                  "git config sendemail.to u-boot@lists.denx.de")
405            return
406    cc = BuildEmailList(list(set(series.get('cc')) - set(series.get('to'))),
407                        '--cc', alias, raise_on_error)
408    if self_only:
409        to = BuildEmailList([os.getenv('USER')], '--to', alias, raise_on_error)
410        cc = []
411    cmd = ['git', 'send-email', '--annotate']
412    if smtp_server:
413        cmd.append('--smtp-server=%s' % smtp_server)
414    if in_reply_to:
415        cmd.append('--in-reply-to="%s"' % tools.FromUnicode(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