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