patman: RunPipe() should not pipe stdout/stderr unless asked
[karo-tx-uboot.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                              capture_stderr=True)
157     if result.return_code != 0:
158         raise OSError, 'git checkout (%s): %s' % (pipe, result.stderr)
159
160 def 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
172 def 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
188 def 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
220 def 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
266 def 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
351 def 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
439 def 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
453 def 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
465 def 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
474 def 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
483 def 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
495 def 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
503 if __name__ == "__main__":
504     import doctest
505
506     doctest.testmod()