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