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