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