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