patman: Fix detection of git version
[oweals/u-boot.git] / tools / patman / gitutil.py
1 # Copyright (c) 2011 The Chromium OS Authors.
2 #
3 # SPDX-License-Identifier:      GPL-2.0+
4 #
5
6 import command
7 import re
8 import os
9 import series
10 import subprocess
11 import sys
12 import terminal
13
14 import checkpatch
15 import settings
16
17 # True to use --no-decorate - we check this in Setup()
18 use_no_decorate = True
19
20 def 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
49 def 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
64 def 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
92 def 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
107 def 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
126 def 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
140 def 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     if result.return_code != 0:
157         raise OSError, 'git checkout (%s): %s' % (pipe, result.stderr)
158
159 def Clone(git_dir, output_dir):
160     """Checkout the selected commit for this build
161
162     Args:
163         commit_hash: Commit hash to check out
164     """
165     pipe = ['git', 'clone', git_dir, '.']
166     result = command.RunPipe([pipe], capture=True, cwd=output_dir)
167     if result.return_code != 0:
168         raise OSError, 'git clone: %s' % result.stderr
169
170 def Fetch(git_dir=None, work_tree=None):
171     """Fetch from the origin repo
172
173     Args:
174         commit_hash: Commit hash to check out
175     """
176     pipe = ['git']
177     if git_dir:
178         pipe.extend(['--git-dir', git_dir])
179     if work_tree:
180         pipe.extend(['--work-tree', work_tree])
181     pipe.append('fetch')
182     result = command.RunPipe([pipe], capture=True)
183     if result.return_code != 0:
184         raise OSError, 'git fetch: %s' % result.stderr
185
186 def CreatePatches(start, count, series):
187     """Create a series of patches from the top of the current branch.
188
189     The patch files are written to the current directory using
190     git format-patch.
191
192     Args:
193         start: Commit to start from: 0=HEAD, 1=next one, etc.
194         count: number of commits to include
195     Return:
196         Filename of cover letter
197         List of filenames of patch files
198     """
199     if series.get('version'):
200         version = '%s ' % series['version']
201     cmd = ['git', 'format-patch', '-M', '--signoff']
202     if series.get('cover'):
203         cmd.append('--cover-letter')
204     prefix = series.GetPatchPrefix()
205     if prefix:
206         cmd += ['--subject-prefix=%s' % prefix]
207     cmd += ['HEAD~%d..HEAD~%d' % (start + count, start)]
208
209     stdout = command.RunList(cmd)
210     files = stdout.splitlines()
211
212     # We have an extra file if there is a cover letter
213     if series.get('cover'):
214        return files[0], files[1:]
215     else:
216        return None, files
217
218 def BuildEmailList(in_list, tag=None, alias=None, raise_on_error=True):
219     """Build a list of email addresses based on an input list.
220
221     Takes a list of email addresses and aliases, and turns this into a list
222     of only email address, by resolving any aliases that are present.
223
224     If the tag is given, then each email address is prepended with this
225     tag and a space. If the tag starts with a minus sign (indicating a
226     command line parameter) then the email address is quoted.
227
228     Args:
229         in_list:        List of aliases/email addresses
230         tag:            Text to put before each address
231         alias:          Alias dictionary
232         raise_on_error: True to raise an error when an alias fails to match,
233                 False to just print a message.
234
235     Returns:
236         List of email addresses
237
238     >>> alias = {}
239     >>> alias['fred'] = ['f.bloggs@napier.co.nz']
240     >>> alias['john'] = ['j.bloggs@napier.co.nz']
241     >>> alias['mary'] = ['Mary Poppins <m.poppins@cloud.net>']
242     >>> alias['boys'] = ['fred', ' john']
243     >>> alias['all'] = ['fred ', 'john', '   mary   ']
244     >>> BuildEmailList(['john', 'mary'], None, alias)
245     ['j.bloggs@napier.co.nz', 'Mary Poppins <m.poppins@cloud.net>']
246     >>> BuildEmailList(['john', 'mary'], '--to', alias)
247     ['--to "j.bloggs@napier.co.nz"', \
248 '--to "Mary Poppins <m.poppins@cloud.net>"']
249     >>> BuildEmailList(['john', 'mary'], 'Cc', alias)
250     ['Cc j.bloggs@napier.co.nz', 'Cc Mary Poppins <m.poppins@cloud.net>']
251     """
252     quote = '"' if tag and tag[0] == '-' else ''
253     raw = []
254     for item in in_list:
255         raw += LookupEmail(item, alias, raise_on_error=raise_on_error)
256     result = []
257     for item in raw:
258         if not item in result:
259             result.append(item)
260     if tag:
261         return ['%s %s%s%s' % (tag, quote, email, quote) for email in result]
262     return result
263
264 def EmailPatches(series, cover_fname, args, dry_run, raise_on_error, cc_fname,
265         self_only=False, alias=None, in_reply_to=None):
266     """Email a patch series.
267
268     Args:
269         series: Series object containing destination info
270         cover_fname: filename of cover letter
271         args: list of filenames of patch files
272         dry_run: Just return the command that would be run
273         raise_on_error: True to raise an error when an alias fails to match,
274                 False to just print a message.
275         cc_fname: Filename of Cc file for per-commit Cc
276         self_only: True to just email to yourself as a test
277         in_reply_to: If set we'll pass this to git as --in-reply-to.
278             Should be a message ID that this is in reply to.
279
280     Returns:
281         Git command that was/would be run
282
283     # For the duration of this doctest pretend that we ran patman with ./patman
284     >>> _old_argv0 = sys.argv[0]
285     >>> sys.argv[0] = './patman'
286
287     >>> alias = {}
288     >>> alias['fred'] = ['f.bloggs@napier.co.nz']
289     >>> alias['john'] = ['j.bloggs@napier.co.nz']
290     >>> alias['mary'] = ['m.poppins@cloud.net']
291     >>> alias['boys'] = ['fred', ' john']
292     >>> alias['all'] = ['fred ', 'john', '   mary   ']
293     >>> alias[os.getenv('USER')] = ['this-is-me@me.com']
294     >>> series = series.Series()
295     >>> series.to = ['fred']
296     >>> series.cc = ['mary']
297     >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \
298             False, alias)
299     'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \
300 "m.poppins@cloud.net" --cc-cmd "./patman --cc-cmd cc-fname" cover p1 p2'
301     >>> EmailPatches(series, None, ['p1'], True, True, 'cc-fname', False, \
302             alias)
303     'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \
304 "m.poppins@cloud.net" --cc-cmd "./patman --cc-cmd cc-fname" p1'
305     >>> series.cc = ['all']
306     >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \
307             True, alias)
308     'git send-email --annotate --to "this-is-me@me.com" --cc-cmd "./patman \
309 --cc-cmd cc-fname" cover p1 p2'
310     >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \
311             False, alias)
312     'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \
313 "f.bloggs@napier.co.nz" --cc "j.bloggs@napier.co.nz" --cc \
314 "m.poppins@cloud.net" --cc-cmd "./patman --cc-cmd cc-fname" cover p1 p2'
315
316     # Restore argv[0] since we clobbered it.
317     >>> sys.argv[0] = _old_argv0
318     """
319     to = BuildEmailList(series.get('to'), '--to', alias, raise_on_error)
320     if not to:
321         git_config_to = command.Output('git', 'config', 'sendemail.to')
322         if not git_config_to:
323             print ("No recipient.\n"
324                    "Please add something like this to a commit\n"
325                    "Series-to: Fred Bloggs <f.blogs@napier.co.nz>\n"
326                    "Or do something like this\n"
327                    "git config sendemail.to u-boot@lists.denx.de")
328             return
329     cc = BuildEmailList(series.get('cc'), '--cc', alias, raise_on_error)
330     if self_only:
331         to = BuildEmailList([os.getenv('USER')], '--to', alias, raise_on_error)
332         cc = []
333     cmd = ['git', 'send-email', '--annotate']
334     if in_reply_to:
335         cmd.append('--in-reply-to="%s"' % in_reply_to)
336
337     cmd += to
338     cmd += cc
339     cmd += ['--cc-cmd', '"%s --cc-cmd %s"' % (sys.argv[0], cc_fname)]
340     if cover_fname:
341         cmd.append(cover_fname)
342     cmd += args
343     str = ' '.join(cmd)
344     if not dry_run:
345         os.system(str)
346     return str
347
348
349 def LookupEmail(lookup_name, alias=None, raise_on_error=True, level=0):
350     """If an email address is an alias, look it up and return the full name
351
352     TODO: Why not just use git's own alias feature?
353
354     Args:
355         lookup_name: Alias or email address to look up
356         alias: Dictionary containing aliases (None to use settings default)
357         raise_on_error: True to raise an error when an alias fails to match,
358                 False to just print a message.
359
360     Returns:
361         tuple:
362             list containing a list of email addresses
363
364     Raises:
365         OSError if a recursive alias reference was found
366         ValueError if an alias was not found
367
368     >>> alias = {}
369     >>> alias['fred'] = ['f.bloggs@napier.co.nz']
370     >>> alias['john'] = ['j.bloggs@napier.co.nz']
371     >>> alias['mary'] = ['m.poppins@cloud.net']
372     >>> alias['boys'] = ['fred', ' john', 'f.bloggs@napier.co.nz']
373     >>> alias['all'] = ['fred ', 'john', '   mary   ']
374     >>> alias['loop'] = ['other', 'john', '   mary   ']
375     >>> alias['other'] = ['loop', 'john', '   mary   ']
376     >>> LookupEmail('mary', alias)
377     ['m.poppins@cloud.net']
378     >>> LookupEmail('arthur.wellesley@howe.ro.uk', alias)
379     ['arthur.wellesley@howe.ro.uk']
380     >>> LookupEmail('boys', alias)
381     ['f.bloggs@napier.co.nz', 'j.bloggs@napier.co.nz']
382     >>> LookupEmail('all', alias)
383     ['f.bloggs@napier.co.nz', 'j.bloggs@napier.co.nz', 'm.poppins@cloud.net']
384     >>> LookupEmail('odd', alias)
385     Traceback (most recent call last):
386     ...
387     ValueError: Alias 'odd' not found
388     >>> LookupEmail('loop', alias)
389     Traceback (most recent call last):
390     ...
391     OSError: Recursive email alias at 'other'
392     >>> LookupEmail('odd', alias, raise_on_error=False)
393     Alias 'odd' not found
394     []
395     >>> # In this case the loop part will effectively be ignored.
396     >>> LookupEmail('loop', alias, raise_on_error=False)
397     Recursive email alias at 'other'
398     Recursive email alias at 'john'
399     Recursive email alias at 'mary'
400     ['j.bloggs@napier.co.nz', 'm.poppins@cloud.net']
401     """
402     if not alias:
403         alias = settings.alias
404     lookup_name = lookup_name.strip()
405     if '@' in lookup_name: # Perhaps a real email address
406         return [lookup_name]
407
408     lookup_name = lookup_name.lower()
409     col = terminal.Color()
410
411     out_list = []
412     if level > 10:
413         msg = "Recursive email alias at '%s'" % lookup_name
414         if raise_on_error:
415             raise OSError, msg
416         else:
417             print col.Color(col.RED, msg)
418             return out_list
419
420     if lookup_name:
421         if not lookup_name in alias:
422             msg = "Alias '%s' not found" % lookup_name
423             if raise_on_error:
424                 raise ValueError, msg
425             else:
426                 print col.Color(col.RED, msg)
427                 return out_list
428         for item in alias[lookup_name]:
429             todo = LookupEmail(item, alias, raise_on_error, level + 1)
430             for new_item in todo:
431                 if not new_item in out_list:
432                     out_list.append(new_item)
433
434     #print "No match for alias '%s'" % lookup_name
435     return out_list
436
437 def GetTopLevel():
438     """Return name of top-level directory for this git repo.
439
440     Returns:
441         Full path to git top-level directory
442
443     This test makes sure that we are running tests in the right subdir
444
445     >>> os.path.realpath(os.path.dirname(__file__)) == \
446             os.path.join(GetTopLevel(), 'tools', 'patman')
447     True
448     """
449     return command.OutputOneLine('git', 'rev-parse', '--show-toplevel')
450
451 def GetAliasFile():
452     """Gets the name of the git alias file.
453
454     Returns:
455         Filename of git alias file, or None if none
456     """
457     fname = command.OutputOneLine('git', 'config', 'sendemail.aliasesfile',
458             raise_on_error=False)
459     if fname:
460         fname = os.path.join(GetTopLevel(), fname.strip())
461     return fname
462
463 def GetDefaultUserName():
464     """Gets the user.name from .gitconfig file.
465
466     Returns:
467         User name found in .gitconfig file, or None if none
468     """
469     uname = command.OutputOneLine('git', 'config', '--global', 'user.name')
470     return uname
471
472 def GetDefaultUserEmail():
473     """Gets the user.email from the global .gitconfig file.
474
475     Returns:
476         User's email found in .gitconfig file, or None if none
477     """
478     uemail = command.OutputOneLine('git', 'config', '--global', 'user.email')
479     return uemail
480
481 def Setup():
482     """Set up git utils, by reading the alias files."""
483     # Check for a git alias file also
484     global use_no_decorate
485
486     alias_fname = GetAliasFile()
487     if alias_fname:
488         settings.ReadGitAliases(alias_fname)
489     cmd = LogCmd(None, count=0)
490     use_no_decorate = (command.RunPipe([cmd], raise_on_error=False)
491                        .return_code == 0)
492
493 def GetHead():
494     """Get the hash of the current HEAD
495
496     Returns:
497         Hash of HEAD
498     """
499     return command.OutputOneLine('git', 'show', '-s', '--pretty=format:%H')
500
501 if __name__ == "__main__":
502     import doctest
503
504     doctest.testmod()