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