xref: /rk3399_rockchip-uboot/tools/patman/gitutil.py (revision cda2a611520e679495e7d2cf8e436b0df9afc675)
1# Copyright (c) 2011 The Chromium OS Authors.
2#
3# SPDX-License-Identifier:	GPL-2.0+
4#
5
6import command
7import re
8import os
9import series
10import subprocess
11import sys
12import terminal
13
14import checkpatch
15import settings
16
17def LogCmd(commit_range, git_dir=None, oneline=False, reverse=False,
18           count=None):
19    """Create a command to perform a 'git log'
20
21    Args:
22        commit_range: Range expression to use for log, None for none
23        git_dir: Path to git repositiory (None to use default)
24        oneline: True to use --oneline, else False
25        reverse: True to reverse the log (--reverse)
26        count: Number of commits to list, or None for no limit
27    Return:
28        List containing command and arguments to run
29    """
30    cmd = ['git']
31    if git_dir:
32        cmd += ['--git-dir', git_dir]
33    cmd += ['log', '--no-color']
34    if oneline:
35        cmd.append('--oneline')
36    cmd.append('--no-decorate')
37    if count is not None:
38        cmd.append('-n%d' % count)
39    if commit_range:
40        cmd.append(commit_range)
41    return cmd
42
43def CountCommitsToBranch():
44    """Returns number of commits between HEAD and the tracking branch.
45
46    This looks back to the tracking branch and works out the number of commits
47    since then.
48
49    Return:
50        Number of patches that exist on top of the branch
51    """
52    pipe = [LogCmd('@{upstream}..', oneline=True),
53            ['wc', '-l']]
54    stdout = command.RunPipe(pipe, capture=True, oneline=True).stdout
55    patch_count = int(stdout)
56    return patch_count
57
58def GetUpstream(git_dir, branch):
59    """Returns the name of the upstream for a branch
60
61    Args:
62        git_dir: Git directory containing repo
63        branch: Name of branch
64
65    Returns:
66        Name of upstream branch (e.g. 'upstream/master') or None if none
67    """
68    try:
69        remote = command.OutputOneLine('git', '--git-dir', git_dir, 'config',
70                                       'branch.%s.remote' % branch)
71        merge = command.OutputOneLine('git', '--git-dir', git_dir, 'config',
72                                      'branch.%s.merge' % branch)
73    except:
74        return None
75
76    if remote == '.':
77        return merge
78    elif remote and merge:
79        leaf = merge.split('/')[-1]
80        return '%s/%s' % (remote, leaf)
81    else:
82        raise ValueError, ("Cannot determine upstream branch for branch "
83                "'%s' remote='%s', merge='%s'" % (branch, remote, merge))
84
85
86def GetRangeInBranch(git_dir, branch, include_upstream=False):
87    """Returns an expression for the commits in the given branch.
88
89    Args:
90        git_dir: Directory containing git repo
91        branch: Name of branch
92    Return:
93        Expression in the form 'upstream..branch' which can be used to
94        access the commits. If the branch does not exist, returns None.
95    """
96    upstream = GetUpstream(git_dir, branch)
97    if not upstream:
98        return None
99    return '%s%s..%s' % (upstream, '~' if include_upstream else '', branch)
100
101def CountCommitsInBranch(git_dir, branch, include_upstream=False):
102    """Returns the number of commits in the given branch.
103
104    Args:
105        git_dir: Directory containing git repo
106        branch: Name of branch
107    Return:
108        Number of patches that exist on top of the branch, or None if the
109        branch does not exist.
110    """
111    range_expr = GetRangeInBranch(git_dir, branch, include_upstream)
112    if not range_expr:
113        return None
114    pipe = [LogCmd(range_expr, git_dir=git_dir, oneline=True),
115            ['wc', '-l']]
116    result = command.RunPipe(pipe, capture=True, oneline=True)
117    patch_count = int(result.stdout)
118    return patch_count
119
120def CountCommits(commit_range):
121    """Returns the number of commits in the given range.
122
123    Args:
124        commit_range: Range of commits to count (e.g. 'HEAD..base')
125    Return:
126        Number of patches that exist on top of the branch
127    """
128    pipe = [LogCmd(commit_range, oneline=True),
129            ['wc', '-l']]
130    stdout = command.RunPipe(pipe, capture=True, oneline=True).stdout
131    patch_count = int(stdout)
132    return patch_count
133
134def Checkout(commit_hash, git_dir=None, work_tree=None, force=False):
135    """Checkout the selected commit for this build
136
137    Args:
138        commit_hash: Commit hash to check out
139    """
140    pipe = ['git']
141    if git_dir:
142        pipe.extend(['--git-dir', git_dir])
143    if work_tree:
144        pipe.extend(['--work-tree', work_tree])
145    pipe.append('checkout')
146    if force:
147        pipe.append('-f')
148    pipe.append(commit_hash)
149    result = command.RunPipe([pipe], capture=True, raise_on_error=False)
150    if result.return_code != 0:
151        raise OSError, 'git checkout (%s): %s' % (pipe, result.stderr)
152
153def Clone(git_dir, output_dir):
154    """Checkout the selected commit for this build
155
156    Args:
157        commit_hash: Commit hash to check out
158    """
159    pipe = ['git', 'clone', git_dir, '.']
160    result = command.RunPipe([pipe], capture=True, cwd=output_dir)
161    if result.return_code != 0:
162        raise OSError, 'git clone: %s' % result.stderr
163
164def Fetch(git_dir=None, work_tree=None):
165    """Fetch from the origin repo
166
167    Args:
168        commit_hash: Commit hash to check out
169    """
170    pipe = ['git']
171    if git_dir:
172        pipe.extend(['--git-dir', git_dir])
173    if work_tree:
174        pipe.extend(['--work-tree', work_tree])
175    pipe.append('fetch')
176    result = command.RunPipe([pipe], capture=True)
177    if result.return_code != 0:
178        raise OSError, 'git fetch: %s' % result.stderr
179
180def CreatePatches(start, count, series):
181    """Create a series of patches from the top of the current branch.
182
183    The patch files are written to the current directory using
184    git format-patch.
185
186    Args:
187        start: Commit to start from: 0=HEAD, 1=next one, etc.
188        count: number of commits to include
189    Return:
190        Filename of cover letter
191        List of filenames of patch files
192    """
193    if series.get('version'):
194        version = '%s ' % series['version']
195    cmd = ['git', 'format-patch', '-M', '--signoff']
196    if series.get('cover'):
197        cmd.append('--cover-letter')
198    prefix = series.GetPatchPrefix()
199    if prefix:
200        cmd += ['--subject-prefix=%s' % prefix]
201    cmd += ['HEAD~%d..HEAD~%d' % (start + count, start)]
202
203    stdout = command.RunList(cmd)
204    files = stdout.splitlines()
205
206    # We have an extra file if there is a cover letter
207    if series.get('cover'):
208       return files[0], files[1:]
209    else:
210       return None, files
211
212def ApplyPatch(verbose, fname):
213    """Apply a patch with git am to test it
214
215    TODO: Convert these to use command, with stderr option
216
217    Args:
218        fname: filename of patch file to apply
219    """
220    col = terminal.Color()
221    cmd = ['git', 'am', fname]
222    pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE,
223            stderr=subprocess.PIPE)
224    stdout, stderr = pipe.communicate()
225    re_error = re.compile('^error: patch failed: (.+):(\d+)')
226    for line in stderr.splitlines():
227        if verbose:
228            print line
229        match = re_error.match(line)
230        if match:
231            print checkpatch.GetWarningMsg(col, 'warning', match.group(1),
232                                           int(match.group(2)), 'Patch failed')
233    return pipe.returncode == 0, stdout
234
235def ApplyPatches(verbose, args, start_point):
236    """Apply the patches with git am to make sure all is well
237
238    Args:
239        verbose: Print out 'git am' output verbatim
240        args: List of patch files to apply
241        start_point: Number of commits back from HEAD to start applying.
242            Normally this is len(args), but it can be larger if a start
243            offset was given.
244    """
245    error_count = 0
246    col = terminal.Color()
247
248    # Figure out our current position
249    cmd = ['git', 'name-rev', 'HEAD', '--name-only']
250    pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE)
251    stdout, stderr = pipe.communicate()
252    if pipe.returncode:
253        str = 'Could not find current commit name'
254        print col.Color(col.RED, str)
255        print stdout
256        return False
257    old_head = stdout.splitlines()[0]
258    if old_head == 'undefined':
259        str = "Invalid HEAD '%s'" % stdout.strip()
260        print col.Color(col.RED, str)
261        return False
262
263    # Checkout the required start point
264    cmd = ['git', 'checkout', 'HEAD~%d' % start_point]
265    pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE,
266            stderr=subprocess.PIPE)
267    stdout, stderr = pipe.communicate()
268    if pipe.returncode:
269        str = 'Could not move to commit before patch series'
270        print col.Color(col.RED, str)
271        print stdout, stderr
272        return False
273
274    # Apply all the patches
275    for fname in args:
276        ok, stdout = ApplyPatch(verbose, fname)
277        if not ok:
278            print col.Color(col.RED, 'git am returned errors for %s: will '
279                    'skip this patch' % fname)
280            if verbose:
281                print stdout
282            error_count += 1
283            cmd = ['git', 'am', '--skip']
284            pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE)
285            stdout, stderr = pipe.communicate()
286            if pipe.returncode != 0:
287                print col.Color(col.RED, 'Unable to skip patch! Aborting...')
288                print stdout
289                break
290
291    # Return to our previous position
292    cmd = ['git', 'checkout', old_head]
293    pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
294    stdout, stderr = pipe.communicate()
295    if pipe.returncode:
296        print col.Color(col.RED, 'Could not move back to head commit')
297        print stdout, stderr
298    return error_count == 0
299
300def BuildEmailList(in_list, tag=None, alias=None, raise_on_error=True):
301    """Build a list of email addresses based on an input list.
302
303    Takes a list of email addresses and aliases, and turns this into a list
304    of only email address, by resolving any aliases that are present.
305
306    If the tag is given, then each email address is prepended with this
307    tag and a space. If the tag starts with a minus sign (indicating a
308    command line parameter) then the email address is quoted.
309
310    Args:
311        in_list:        List of aliases/email addresses
312        tag:            Text to put before each address
313        alias:          Alias dictionary
314        raise_on_error: True to raise an error when an alias fails to match,
315                False to just print a message.
316
317    Returns:
318        List of email addresses
319
320    >>> alias = {}
321    >>> alias['fred'] = ['f.bloggs@napier.co.nz']
322    >>> alias['john'] = ['j.bloggs@napier.co.nz']
323    >>> alias['mary'] = ['Mary Poppins <m.poppins@cloud.net>']
324    >>> alias['boys'] = ['fred', ' john']
325    >>> alias['all'] = ['fred ', 'john', '   mary   ']
326    >>> BuildEmailList(['john', 'mary'], None, alias)
327    ['j.bloggs@napier.co.nz', 'Mary Poppins <m.poppins@cloud.net>']
328    >>> BuildEmailList(['john', 'mary'], '--to', alias)
329    ['--to "j.bloggs@napier.co.nz"', \
330'--to "Mary Poppins <m.poppins@cloud.net>"']
331    >>> BuildEmailList(['john', 'mary'], 'Cc', alias)
332    ['Cc j.bloggs@napier.co.nz', 'Cc Mary Poppins <m.poppins@cloud.net>']
333    """
334    quote = '"' if tag and tag[0] == '-' else ''
335    raw = []
336    for item in in_list:
337        raw += LookupEmail(item, alias, raise_on_error=raise_on_error)
338    result = []
339    for item in raw:
340        if not item in result:
341            result.append(item)
342    if tag:
343        return ['%s %s%s%s' % (tag, quote, email, quote) for email in result]
344    return result
345
346def EmailPatches(series, cover_fname, args, dry_run, raise_on_error, cc_fname,
347        self_only=False, alias=None, in_reply_to=None):
348    """Email a patch series.
349
350    Args:
351        series: Series object containing destination info
352        cover_fname: filename of cover letter
353        args: list of filenames of patch files
354        dry_run: Just return the command that would be run
355        raise_on_error: True to raise an error when an alias fails to match,
356                False to just print a message.
357        cc_fname: Filename of Cc file for per-commit Cc
358        self_only: True to just email to yourself as a test
359        in_reply_to: If set we'll pass this to git as --in-reply-to.
360            Should be a message ID that this is in reply to.
361
362    Returns:
363        Git command that was/would be run
364
365    # For the duration of this doctest pretend that we ran patman with ./patman
366    >>> _old_argv0 = sys.argv[0]
367    >>> sys.argv[0] = './patman'
368
369    >>> alias = {}
370    >>> alias['fred'] = ['f.bloggs@napier.co.nz']
371    >>> alias['john'] = ['j.bloggs@napier.co.nz']
372    >>> alias['mary'] = ['m.poppins@cloud.net']
373    >>> alias['boys'] = ['fred', ' john']
374    >>> alias['all'] = ['fred ', 'john', '   mary   ']
375    >>> alias[os.getenv('USER')] = ['this-is-me@me.com']
376    >>> series = series.Series()
377    >>> series.to = ['fred']
378    >>> series.cc = ['mary']
379    >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \
380            False, alias)
381    'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \
382"m.poppins@cloud.net" --cc-cmd "./patman --cc-cmd cc-fname" cover p1 p2'
383    >>> EmailPatches(series, None, ['p1'], True, True, 'cc-fname', False, \
384            alias)
385    'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \
386"m.poppins@cloud.net" --cc-cmd "./patman --cc-cmd cc-fname" p1'
387    >>> series.cc = ['all']
388    >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \
389            True, alias)
390    'git send-email --annotate --to "this-is-me@me.com" --cc-cmd "./patman \
391--cc-cmd cc-fname" cover p1 p2'
392    >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \
393            False, alias)
394    'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \
395"f.bloggs@napier.co.nz" --cc "j.bloggs@napier.co.nz" --cc \
396"m.poppins@cloud.net" --cc-cmd "./patman --cc-cmd cc-fname" cover p1 p2'
397
398    # Restore argv[0] since we clobbered it.
399    >>> sys.argv[0] = _old_argv0
400    """
401    to = BuildEmailList(series.get('to'), '--to', alias, raise_on_error)
402    if not to:
403        git_config_to = command.Output('git', 'config', 'sendemail.to')
404        if not git_config_to:
405            print ("No recipient.\n"
406                   "Please add something like this to a commit\n"
407                   "Series-to: Fred Bloggs <f.blogs@napier.co.nz>\n"
408                   "Or do something like this\n"
409                   "git config sendemail.to u-boot@lists.denx.de")
410            return
411    cc = BuildEmailList(series.get('cc'), '--cc', alias, raise_on_error)
412    if self_only:
413        to = BuildEmailList([os.getenv('USER')], '--to', alias, raise_on_error)
414        cc = []
415    cmd = ['git', 'send-email', '--annotate']
416    if in_reply_to:
417        cmd.append('--in-reply-to="%s"' % in_reply_to)
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    str = ' '.join(cmd)
426    if not dry_run:
427        os.system(str)
428    return str
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    \033[1;31mAlias 'odd' not found\033[0m
476    []
477    >>> # In this case the loop part will effectively be ignored.
478    >>> LookupEmail('loop', alias, raise_on_error=False)
479    \033[1;31mRecursive email alias at 'other'\033[0m
480    \033[1;31mRecursive email alias at 'john'\033[0m
481    \033[1;31mRecursive email alias at 'mary'\033[0m
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 Setup():
564    """Set up git utils, by reading the alias files."""
565    # Check for a git alias file also
566    alias_fname = GetAliasFile()
567    if alias_fname:
568        settings.ReadGitAliases(alias_fname)
569
570def GetHead():
571    """Get the hash of the current HEAD
572
573    Returns:
574        Hash of HEAD
575    """
576    return command.OutputOneLine('git', 'show', '-s', '--pretty=format:%H')
577
578if __name__ == "__main__":
579    import doctest
580
581    doctest.testmod()
582