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