patman: Correct unit tests to run correctly
[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 += ['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 ApplyPatch(verbose, fname):
219     """Apply a patch with git am to test it
220
221     TODO: Convert these to use command, with stderr option
222
223     Args:
224         fname: filename of patch file to apply
225     """
226     col = terminal.Color()
227     cmd = ['git', 'am', fname]
228     pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE,
229             stderr=subprocess.PIPE)
230     stdout, stderr = pipe.communicate()
231     re_error = re.compile('^error: patch failed: (.+):(\d+)')
232     for line in stderr.splitlines():
233         if verbose:
234             print line
235         match = re_error.match(line)
236         if match:
237             print checkpatch.GetWarningMsg(col, 'warning', match.group(1),
238                                            int(match.group(2)), 'Patch failed')
239     return pipe.returncode == 0, stdout
240
241 def ApplyPatches(verbose, args, start_point):
242     """Apply the patches with git am to make sure all is well
243
244     Args:
245         verbose: Print out 'git am' output verbatim
246         args: List of patch files to apply
247         start_point: Number of commits back from HEAD to start applying.
248             Normally this is len(args), but it can be larger if a start
249             offset was given.
250     """
251     error_count = 0
252     col = terminal.Color()
253
254     # Figure out our current position
255     cmd = ['git', 'name-rev', 'HEAD', '--name-only']
256     pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE)
257     stdout, stderr = pipe.communicate()
258     if pipe.returncode:
259         str = 'Could not find current commit name'
260         print col.Color(col.RED, str)
261         print stdout
262         return False
263     old_head = stdout.splitlines()[0]
264     if old_head == 'undefined':
265         str = "Invalid HEAD '%s'" % stdout.strip()
266         print col.Color(col.RED, str)
267         return False
268
269     # Checkout the required start point
270     cmd = ['git', 'checkout', 'HEAD~%d' % start_point]
271     pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE,
272             stderr=subprocess.PIPE)
273     stdout, stderr = pipe.communicate()
274     if pipe.returncode:
275         str = 'Could not move to commit before patch series'
276         print col.Color(col.RED, str)
277         print stdout, stderr
278         return False
279
280     # Apply all the patches
281     for fname in args:
282         ok, stdout = ApplyPatch(verbose, fname)
283         if not ok:
284             print col.Color(col.RED, 'git am returned errors for %s: will '
285                     'skip this patch' % fname)
286             if verbose:
287                 print stdout
288             error_count += 1
289             cmd = ['git', 'am', '--skip']
290             pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE)
291             stdout, stderr = pipe.communicate()
292             if pipe.returncode != 0:
293                 print col.Color(col.RED, 'Unable to skip patch! Aborting...')
294                 print stdout
295                 break
296
297     # Return to our previous position
298     cmd = ['git', 'checkout', old_head]
299     pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
300     stdout, stderr = pipe.communicate()
301     if pipe.returncode:
302         print col.Color(col.RED, 'Could not move back to head commit')
303         print stdout, stderr
304     return error_count == 0
305
306 def BuildEmailList(in_list, tag=None, alias=None, raise_on_error=True):
307     """Build a list of email addresses based on an input list.
308
309     Takes a list of email addresses and aliases, and turns this into a list
310     of only email address, by resolving any aliases that are present.
311
312     If the tag is given, then each email address is prepended with this
313     tag and a space. If the tag starts with a minus sign (indicating a
314     command line parameter) then the email address is quoted.
315
316     Args:
317         in_list:        List of aliases/email addresses
318         tag:            Text to put before each address
319         alias:          Alias dictionary
320         raise_on_error: True to raise an error when an alias fails to match,
321                 False to just print a message.
322
323     Returns:
324         List of email addresses
325
326     >>> alias = {}
327     >>> alias['fred'] = ['f.bloggs@napier.co.nz']
328     >>> alias['john'] = ['j.bloggs@napier.co.nz']
329     >>> alias['mary'] = ['Mary Poppins <m.poppins@cloud.net>']
330     >>> alias['boys'] = ['fred', ' john']
331     >>> alias['all'] = ['fred ', 'john', '   mary   ']
332     >>> BuildEmailList(['john', 'mary'], None, alias)
333     ['j.bloggs@napier.co.nz', 'Mary Poppins <m.poppins@cloud.net>']
334     >>> BuildEmailList(['john', 'mary'], '--to', alias)
335     ['--to "j.bloggs@napier.co.nz"', \
336 '--to "Mary Poppins <m.poppins@cloud.net>"']
337     >>> BuildEmailList(['john', 'mary'], 'Cc', alias)
338     ['Cc j.bloggs@napier.co.nz', 'Cc Mary Poppins <m.poppins@cloud.net>']
339     """
340     quote = '"' if tag and tag[0] == '-' else ''
341     raw = []
342     for item in in_list:
343         raw += LookupEmail(item, alias, raise_on_error=raise_on_error)
344     result = []
345     for item in raw:
346         if not item in result:
347             result.append(item)
348     if tag:
349         return ['%s %s%s%s' % (tag, quote, email, quote) for email in result]
350     return result
351
352 def EmailPatches(series, cover_fname, args, dry_run, raise_on_error, cc_fname,
353         self_only=False, alias=None, in_reply_to=None):
354     """Email a patch series.
355
356     Args:
357         series: Series object containing destination info
358         cover_fname: filename of cover letter
359         args: list of filenames of patch files
360         dry_run: Just return the command that would be run
361         raise_on_error: True to raise an error when an alias fails to match,
362                 False to just print a message.
363         cc_fname: Filename of Cc file for per-commit Cc
364         self_only: True to just email to yourself as a test
365         in_reply_to: If set we'll pass this to git as --in-reply-to.
366             Should be a message ID that this is in reply to.
367
368     Returns:
369         Git command that was/would be run
370
371     # For the duration of this doctest pretend that we ran patman with ./patman
372     >>> _old_argv0 = sys.argv[0]
373     >>> sys.argv[0] = './patman'
374
375     >>> alias = {}
376     >>> alias['fred'] = ['f.bloggs@napier.co.nz']
377     >>> alias['john'] = ['j.bloggs@napier.co.nz']
378     >>> alias['mary'] = ['m.poppins@cloud.net']
379     >>> alias['boys'] = ['fred', ' john']
380     >>> alias['all'] = ['fred ', 'john', '   mary   ']
381     >>> alias[os.getenv('USER')] = ['this-is-me@me.com']
382     >>> series = series.Series()
383     >>> series.to = ['fred']
384     >>> series.cc = ['mary']
385     >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \
386             False, alias)
387     'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \
388 "m.poppins@cloud.net" --cc-cmd "./patman --cc-cmd cc-fname" cover p1 p2'
389     >>> EmailPatches(series, None, ['p1'], True, True, 'cc-fname', False, \
390             alias)
391     'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \
392 "m.poppins@cloud.net" --cc-cmd "./patman --cc-cmd cc-fname" p1'
393     >>> series.cc = ['all']
394     >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \
395             True, alias)
396     'git send-email --annotate --to "this-is-me@me.com" --cc-cmd "./patman \
397 --cc-cmd cc-fname" cover p1 p2'
398     >>> EmailPatches(series, 'cover', ['p1', 'p2'], True, True, 'cc-fname', \
399             False, alias)
400     'git send-email --annotate --to "f.bloggs@napier.co.nz" --cc \
401 "f.bloggs@napier.co.nz" --cc "j.bloggs@napier.co.nz" --cc \
402 "m.poppins@cloud.net" --cc-cmd "./patman --cc-cmd cc-fname" cover p1 p2'
403
404     # Restore argv[0] since we clobbered it.
405     >>> sys.argv[0] = _old_argv0
406     """
407     to = BuildEmailList(series.get('to'), '--to', alias, raise_on_error)
408     if not to:
409         git_config_to = command.Output('git', 'config', 'sendemail.to')
410         if not git_config_to:
411             print ("No recipient.\n"
412                    "Please add something like this to a commit\n"
413                    "Series-to: Fred Bloggs <f.blogs@napier.co.nz>\n"
414                    "Or do something like this\n"
415                    "git config sendemail.to u-boot@lists.denx.de")
416             return
417     cc = BuildEmailList(series.get('cc'), '--cc', alias, raise_on_error)
418     if self_only:
419         to = BuildEmailList([os.getenv('USER')], '--to', alias, raise_on_error)
420         cc = []
421     cmd = ['git', 'send-email', '--annotate']
422     if in_reply_to:
423         cmd.append('--in-reply-to="%s"' % in_reply_to)
424
425     cmd += to
426     cmd += cc
427     cmd += ['--cc-cmd', '"%s --cc-cmd %s"' % (sys.argv[0], cc_fname)]
428     if cover_fname:
429         cmd.append(cover_fname)
430     cmd += args
431     str = ' '.join(cmd)
432     if not dry_run:
433         os.system(str)
434     return str
435
436
437 def LookupEmail(lookup_name, alias=None, raise_on_error=True, level=0):
438     """If an email address is an alias, look it up and return the full name
439
440     TODO: Why not just use git's own alias feature?
441
442     Args:
443         lookup_name: Alias or email address to look up
444         alias: Dictionary containing aliases (None to use settings default)
445         raise_on_error: True to raise an error when an alias fails to match,
446                 False to just print a message.
447
448     Returns:
449         tuple:
450             list containing a list of email addresses
451
452     Raises:
453         OSError if a recursive alias reference was found
454         ValueError if an alias was not found
455
456     >>> alias = {}
457     >>> alias['fred'] = ['f.bloggs@napier.co.nz']
458     >>> alias['john'] = ['j.bloggs@napier.co.nz']
459     >>> alias['mary'] = ['m.poppins@cloud.net']
460     >>> alias['boys'] = ['fred', ' john', 'f.bloggs@napier.co.nz']
461     >>> alias['all'] = ['fred ', 'john', '   mary   ']
462     >>> alias['loop'] = ['other', 'john', '   mary   ']
463     >>> alias['other'] = ['loop', 'john', '   mary   ']
464     >>> LookupEmail('mary', alias)
465     ['m.poppins@cloud.net']
466     >>> LookupEmail('arthur.wellesley@howe.ro.uk', alias)
467     ['arthur.wellesley@howe.ro.uk']
468     >>> LookupEmail('boys', alias)
469     ['f.bloggs@napier.co.nz', 'j.bloggs@napier.co.nz']
470     >>> LookupEmail('all', alias)
471     ['f.bloggs@napier.co.nz', 'j.bloggs@napier.co.nz', 'm.poppins@cloud.net']
472     >>> LookupEmail('odd', alias)
473     Traceback (most recent call last):
474     ...
475     ValueError: Alias 'odd' not found
476     >>> LookupEmail('loop', alias)
477     Traceback (most recent call last):
478     ...
479     OSError: Recursive email alias at 'other'
480     >>> LookupEmail('odd', alias, raise_on_error=False)
481     Alias 'odd' not found
482     []
483     >>> # In this case the loop part will effectively be ignored.
484     >>> LookupEmail('loop', alias, raise_on_error=False)
485     Recursive email alias at 'other'
486     Recursive email alias at 'john'
487     Recursive email alias at 'mary'
488     ['j.bloggs@napier.co.nz', 'm.poppins@cloud.net']
489     """
490     if not alias:
491         alias = settings.alias
492     lookup_name = lookup_name.strip()
493     if '@' in lookup_name: # Perhaps a real email address
494         return [lookup_name]
495
496     lookup_name = lookup_name.lower()
497     col = terminal.Color()
498
499     out_list = []
500     if level > 10:
501         msg = "Recursive email alias at '%s'" % lookup_name
502         if raise_on_error:
503             raise OSError, msg
504         else:
505             print col.Color(col.RED, msg)
506             return out_list
507
508     if lookup_name:
509         if not lookup_name in alias:
510             msg = "Alias '%s' not found" % lookup_name
511             if raise_on_error:
512                 raise ValueError, msg
513             else:
514                 print col.Color(col.RED, msg)
515                 return out_list
516         for item in alias[lookup_name]:
517             todo = LookupEmail(item, alias, raise_on_error, level + 1)
518             for new_item in todo:
519                 if not new_item in out_list:
520                     out_list.append(new_item)
521
522     #print "No match for alias '%s'" % lookup_name
523     return out_list
524
525 def GetTopLevel():
526     """Return name of top-level directory for this git repo.
527
528     Returns:
529         Full path to git top-level directory
530
531     This test makes sure that we are running tests in the right subdir
532
533     >>> os.path.realpath(os.path.dirname(__file__)) == \
534             os.path.join(GetTopLevel(), 'tools', 'patman')
535     True
536     """
537     return command.OutputOneLine('git', 'rev-parse', '--show-toplevel')
538
539 def GetAliasFile():
540     """Gets the name of the git alias file.
541
542     Returns:
543         Filename of git alias file, or None if none
544     """
545     fname = command.OutputOneLine('git', 'config', 'sendemail.aliasesfile',
546             raise_on_error=False)
547     if fname:
548         fname = os.path.join(GetTopLevel(), fname.strip())
549     return fname
550
551 def GetDefaultUserName():
552     """Gets the user.name from .gitconfig file.
553
554     Returns:
555         User name found in .gitconfig file, or None if none
556     """
557     uname = command.OutputOneLine('git', 'config', '--global', 'user.name')
558     return uname
559
560 def GetDefaultUserEmail():
561     """Gets the user.email from the global .gitconfig file.
562
563     Returns:
564         User's email found in .gitconfig file, or None if none
565     """
566     uemail = command.OutputOneLine('git', 'config', '--global', 'user.email')
567     return uemail
568
569 def Setup():
570     """Set up git utils, by reading the alias files."""
571     # Check for a git alias file also
572     alias_fname = GetAliasFile()
573     if alias_fname:
574         settings.ReadGitAliases(alias_fname)
575     cmd = LogCmd(None, count=0)
576     use_no_decorate = (command.RunPipe([cmd], raise_on_error=False)
577                        .return_code == 0)
578
579 def GetHead():
580     """Get the hash of the current HEAD
581
582     Returns:
583         Hash of HEAD
584     """
585     return command.OutputOneLine('git', 'show', '-s', '--pretty=format:%H')
586
587 if __name__ == "__main__":
588     import doctest
589
590     doctest.testmod()