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