1# Copyright (c) 2011 The Chromium OS Authors. 2# 3# SPDX-License-Identifier: GPL-2.0+ 4# 5 6import command 7import re 8import os 9import series 10import subprocess 11import sys 12import terminal 13 14import checkpatch 15import settings 16 17# True to use --no-decorate - we check this in Setup() 18use_no_decorate = True 19 20def 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 49def 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 64def 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 80def 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 110def 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 141def 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 157def 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 176def 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 190def 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 210def 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 222def 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 238def 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 270def 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 316def 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 401def 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 489def 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 503def 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 515def 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 524def 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 533def 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 545def 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 553if __name__ == "__main__": 554 import doctest 555 556 doctest.testmod() 557