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