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