Chromium Code Reviews| OLD | NEW |
|---|---|
| (Empty) | |
| 1 #!/usr/bin/env python | |
| 2 # Copyright (c) 2014 The Chromium Authors. All rights reserved. | |
| 3 # Use of this source code is governed by a BSD-style license that can be | |
| 4 # found in the LICENSE file. | |
| 5 | |
| 6 """Merge/Revert changes to Chromium release branches. | |
| 7 | |
| 8 This will use the git clone in the current directory if it matches the commit | |
| 9 you passed in. Alternately, run this script in an empty directory and it will | |
| 10 clone the appropriate repo for you (using `git cache` to do the smallest amount | |
| 11 of network IO possible). | |
| 12 | |
| 13 This tool is aware of the following repos: | |
| 14 """ | |
| 15 | |
| 16 import argparse | |
| 17 import collections | |
| 18 import multiprocessing | |
| 19 import os | |
| 20 import pprint | |
| 21 import re | |
| 22 import sys | |
| 23 import textwrap | |
| 24 import urllib2 | |
| 25 import urlparse | |
| 26 | |
| 27 from multiprocessing.pool import ThreadPool | |
| 28 | |
| 29 import git_cache | |
| 30 import git_common as git | |
| 31 | |
| 32 from third_party import fancy_urllib | |
| 33 | |
| 34 assert fancy_urllib.can_validate_certs() | |
| 35 | |
| 36 CA_CERTS_FILE = os.path.abspath(os.path.join( | |
| 37 os.path.dirname(__file__), 'third_party', 'boto', 'cacerts', 'cacerts.txt' | |
| 38 )) | |
| 39 | |
| 40 urllib2.install_opener(urllib2.build_opener( | |
| 41 fancy_urllib.FancyRedirectHandler(), | |
| 42 fancy_urllib.FancyHTTPSHandler())) | |
| 43 | |
| 44 | |
| 45 MISSING = object() | |
| 46 | |
| 47 OK_HOST_FMT = '%s.googlesource.com' | |
| 48 OK_REPOS = { | |
| 49 'chrome-internal': ('chrome/src-internal',), | |
| 50 'chromium': ('chromium/src', 'chromium/blink', | |
| 51 'native_client/src/native_client') | |
| 52 } | |
| 53 | |
| 54 def repo_url(host, repo): | |
| 55 assert host in OK_REPOS | |
| 56 assert repo in OK_REPOS[host] | |
| 57 return 'https://%s/%s.git' % (OK_HOST_FMT % host, repo) | |
| 58 | |
| 59 # lambda avoids polluting module with variable names, but still executes at | |
| 60 # import-time. | |
| 61 # 'redefining builtin __doc__' pylint: disable=W0622 | |
| 62 __doc__ += (lambda: '\n'.join([ | |
| 63 ' * %s' % repo_url(host, repo) | |
| 64 for host, repos in OK_REPOS.iteritems() | |
| 65 for repo in repos | |
| 66 ]))() | |
| 67 | |
| 68 | |
| 69 def die(msg, *args): | |
| 70 msg = textwrap.dedent(msg) | |
| 71 if args: | |
| 72 msg = msg % args | |
| 73 print >> sys.stderr, msg | |
| 74 sys.exit(1) | |
| 75 | |
| 76 | |
| 77 def retry(fn, args=(), kwargs=None, on=(), but_not=(), upto=3): | |
| 78 kwargs = kwargs or {} | |
| 79 for attempt in xrange(upto): | |
| 80 try: | |
| 81 return fn(*args, **kwargs) | |
| 82 except but_not: | |
| 83 raise | |
| 84 except on: | |
| 85 if attempt + 1 == upto: | |
| 86 raise | |
| 87 | |
| 88 | |
| 89 ################################################################################ | |
| 90 | |
| 91 | |
| 92 def announce(msg=None, msg_fn=lambda: None): | |
| 93 print | |
| 94 print | |
| 95 print '=' * 80 | |
| 96 if msg: | |
| 97 print textwrap.dedent(msg) | |
| 98 msg_fn() | |
| 99 print '=' * 80 | |
| 100 print | |
| 101 | |
| 102 | |
| 103 def confirm(prompt='Is this correct?', abort='No changes have been made.'): | |
| 104 while True: | |
| 105 v = raw_input('%s (Y/n) ' % prompt) | |
| 106 if v == '' or v in 'Yy': | |
| 107 break | |
| 108 if v in 'Nn': | |
| 109 die('Aborting. %s', abort) | |
| 110 | |
| 111 | |
| 112 def summarize_job(correct_url, commits, target_ref, action): | |
| 113 def _msg_fn(): | |
| 114 preposition = 'to' if action == 'merge' else 'from' | |
| 115 print "Planning to %s %d change%s %s branch %s of %s." % ( | |
| 116 action, len(commits), 's' if len(commits) > 1 else '', | |
| 117 preposition, target_ref.num, correct_url) | |
| 118 print | |
| 119 for commit in commits: | |
| 120 print git.run('show', '-s', '--format=%H\t%s', commit) | |
| 121 announce(msg_fn=_msg_fn) | |
| 122 | |
| 123 | |
| 124 def ensure_working_directory(commits, target_ref): | |
| 125 # TODO(iannucci): check all hashes locally after fetching first | |
| 126 | |
| 127 fetch_specs = [ | |
| 128 '%s:%s' % (target_ref.remote_full_ref, target_ref.remote_full_ref) | |
| 129 ] + commits | |
| 130 | |
| 131 if git.check('rev-parse', '--is-inside-work-tree'): | |
| 132 actual_url = git.get_remote_url('origin') | |
| 133 | |
| 134 if not actual_url or not is_ok_repo(actual_url): | |
| 135 die("""\ | |
| 136 Inside a git repo, but origin's remote URL doesn't match one of the | |
| 137 supported git repos. | |
| 138 Current URL: %s""", actual_url) | |
| 139 | |
| 140 s = git.run('status', '--porcelain') | |
| 141 if s: | |
| 142 die("""\ | |
| 143 Your current directory is usable for the command you specified, but it | |
| 144 appears to be dirty (i.e. there are uncommitted changes). Please commit, | |
| 145 freeze, or stash these changes and run this command again. | |
| 146 | |
| 147 %s""", '\n'.join(' '+l for l in s.splitlines())) | |
| 148 | |
| 149 correct_url = get_correct_url(commits, actual_url) | |
| 150 if correct_url != actual_url: | |
| 151 die("""\ | |
| 152 The specified commits appear to be from a different repo than the repo | |
| 153 in the current directory. | |
| 154 Current Repo: %s | |
| 155 Expected Repo: %s | |
| 156 | |
| 157 Please re-run this script in an empty working directory and we'll fetch | |
| 158 the correct repo.""", actual_url, correct_url) | |
| 159 | |
| 160 m = git_cache.Mirror.from_repo('.') | |
| 161 if m: | |
| 162 m.populate(bootstrap=True, verbose=True) | |
| 163 m.populate(fetch_specs=fetch_specs) | |
| 164 | |
| 165 elif len(os.listdir('.')) == 0: | |
| 166 sample_path = '/path/to/cache' | |
| 167 if sys.platform.startswith('win'): | |
| 168 sample_path = r'X:\path\to\cache' | |
| 169 if not git.config('cache.cachepath'): | |
| 170 die("""\ | |
| 171 Automatic drover checkouts require that you configure your global | |
| 172 cachepath to make these automatic checkouts as fast as possible. Do this | |
| 173 by running: | |
| 174 git config --global cache.cachepath "%s" | |
| 175 | |
| 176 We recommend picking a non-network-mounted path with a decent amount of | |
| 177 space (at least 4GB).""", sample_path) | |
| 178 | |
| 179 correct_url = get_correct_url(commits) | |
| 180 | |
| 181 m = git_cache.Mirror(correct_url) | |
| 182 m.populate(bootstrap=True, verbose=True) | |
| 183 m.populate(fetch_specs=fetch_specs) | |
| 184 git.run('clone', '-s', '--no-checkout', m.mirror_path, '.') | |
| 185 git.run('update-ref', '-d', 'refs/heads/master') | |
| 186 else: | |
| 187 die('You must either invoke this from a git repo, or from an empty dir.') | |
| 188 | |
| 189 for s in [target_ref.local_full_ref] + commits: | |
| 190 git.check('fetch', 'origin', s) | |
| 191 | |
| 192 return correct_url | |
| 193 | |
| 194 | |
| 195 def find_hash_urls(commits, presumed_url=None): | |
| 196 """Returns {url -> [commits]}. | |
| 197 | |
| 198 Args: | |
| 199 commits - list of 40 char commit hashes | |
| 200 presumed_url - the url to the first repo to try. If commits end up not | |
| 201 existing in this repo, find_hash_urls will try all other | |
| 202 known repos. | |
| 203 """ | |
| 204 pool = ThreadPool() | |
| 205 | |
| 206 def process_async_results(asr, results): | |
| 207 """Resolves async results from |asr| into |results|. | |
| 208 | |
| 209 Args: | |
| 210 asr - {commit -> [multiprocessing.pool.AsyncResult]} | |
| 211 The async results are for a url or MISSING. | |
| 212 results (in,out) - defaultdict({url -> set(commits)}) | |
| 213 | |
| 214 Returns a list of commits which did not have any non-MISSING result. | |
|
agable
2014/04/28 21:38:22
"...which had only MISSING results."
| |
| 215 """ | |
| 216 try: | |
| 217 lost_commits = [] | |
| 218 passes = 0 | |
| 219 while asr and passes <= 10: | |
| 220 new_asr = {} | |
| 221 for commit, attempts in asr.iteritems(): | |
| 222 new_attempts = [] | |
| 223 for attempt in attempts: | |
| 224 try: | |
| 225 attempt = attempt.get(.5) | |
| 226 if attempt is not MISSING: | |
| 227 results[attempt].add(commit) | |
| 228 break | |
| 229 except multiprocessing.TimeoutError: | |
| 230 new_attempts.append(attempt) | |
| 231 else: | |
| 232 if new_attempts: | |
| 233 new_asr[commit] = new_attempts | |
| 234 else: | |
| 235 lost_commits.append(commit) | |
| 236 asr = new_asr | |
| 237 passes += 1 | |
| 238 lost_commits += asr.keys() | |
| 239 return lost_commits | |
| 240 except Exception: | |
| 241 import traceback | |
| 242 traceback.print_exc() | |
| 243 | |
| 244 def exists(url, commit): | |
| 245 """Queries the repo at |url| for |commit|. | |
| 246 | |
| 247 Returns MISSING or the repo |url| | |
| 248 """ | |
| 249 query_url = '%s/+/%s?format=JSON' % (url, commit) | |
| 250 return MISSING if GET(query_url) is MISSING else url | |
| 251 | |
| 252 def go_fish(commit, except_for=()): | |
| 253 """Given a |commit|, search for it in all repos simultaneously, except for | |
| 254 repos indicated by |except_for|. | |
| 255 | |
| 256 Returns the repo url for the commit or None. | |
| 257 """ | |
| 258 async_results = {commit: set()} | |
| 259 for host, repos in OK_REPOS.iteritems(): | |
| 260 for repo in repos: | |
| 261 url = repo_url(host, repo) | |
| 262 if url in except_for: | |
| 263 continue | |
| 264 async_results[commit].add( | |
| 265 pool.apply_async(exists, args=(url, commit))) | |
| 266 | |
| 267 results = collections.defaultdict(set) | |
| 268 lost = process_async_results(async_results, results) | |
| 269 if not lost: | |
| 270 return results.popitem()[0] | |
| 271 | |
| 272 # map of url -> set(commits) | |
| 273 results = collections.defaultdict(set) | |
| 274 | |
| 275 # Try to find one hash which matches some repo | |
| 276 while commits and not presumed_url: | |
| 277 presumed_url = go_fish(commits[0]) | |
| 278 results[presumed_url].add(commits[0]) | |
| 279 commits = commits[1:] | |
| 280 | |
| 281 # map of commit -> attempts | |
| 282 async_results = collections.defaultdict(list) | |
| 283 for commit in commits: | |
| 284 async_results[commit].append( | |
| 285 pool.apply_async(exists, args=(presumed_url, commit))) | |
| 286 | |
| 287 lost = process_async_results(async_results, results) | |
| 288 | |
| 289 if lost: | |
| 290 fishing_pool = ThreadPool() | |
| 291 async_results = collections.defaultdict(list) | |
| 292 for commit in lost: | |
| 293 async_results[commit].append( | |
| 294 fishing_pool.apply_async(go_fish, (commit,), | |
| 295 {'except_for': presumed_url}) | |
| 296 ) | |
| 297 lost = process_async_results(async_results, results) | |
| 298 if lost: | |
| 299 results[None].update(lost) | |
| 300 | |
| 301 return {(k or 'UNKNOWN'): list(v) for k, v in results.iteritems()} | |
| 302 | |
| 303 | |
| 304 def GET(url, **kwargs): | |
| 305 try: | |
| 306 kwargs.setdefault('timeout', 5) | |
| 307 request = fancy_urllib.FancyRequest(url) | |
| 308 request.set_ssl_info(ca_certs=CA_CERTS_FILE) | |
| 309 return retry(urllib2.urlopen, [request], kwargs, | |
| 310 on=urllib2.URLError, but_not=urllib2.HTTPError, upto=3) | |
| 311 except urllib2.HTTPError as e: | |
| 312 if e.getcode() / 100 == 4: | |
| 313 return MISSING | |
| 314 raise | |
| 315 | |
| 316 | |
| 317 def get_correct_url(commits, presumed_url=None): | |
| 318 unverified = commits | |
| 319 if presumed_url: | |
| 320 unverified = [c for c in unverified if not git.verify_commit(c)] | |
| 321 if not unverified: | |
| 322 return presumed_url | |
| 323 git.cached_fetch(unverified) | |
| 324 unverified = [c for c in unverified if not git.verify_commit(c)] | |
| 325 if not unverified: | |
| 326 return presumed_url | |
| 327 | |
| 328 url_hashes = find_hash_urls(unverified, presumed_url) | |
| 329 if None in url_hashes: | |
| 330 die("""\ | |
| 331 Could not determine what repo the following commits originate from: | |
| 332 %r""", url_hashes[None]) | |
| 333 | |
| 334 if len(url_hashes) > 1: | |
| 335 die("""\ | |
| 336 Ambiguous commits specified. You supplied multiple commits, but they | |
| 337 appear to be from more than one repo? | |
| 338 %s""", pprint.pformat(dict(url_hashes))) | |
| 339 | |
| 340 return url_hashes.popitem()[0] | |
| 341 | |
| 342 | |
| 343 def is_ok_repo(url): | |
| 344 parsed = urlparse.urlsplit(url) | |
| 345 | |
| 346 if parsed.scheme == 'https': | |
| 347 host = parsed.netloc[:-len(OK_HOST_FMT % '')] | |
| 348 elif parsed.scheme == 'sso': | |
| 349 host = parsed.netloc | |
| 350 else: | |
| 351 return False | |
| 352 | |
| 353 if host not in OK_REPOS: | |
| 354 return False | |
| 355 | |
| 356 path = parsed.path.strip('/') | |
| 357 if path.endswith('.git'): | |
| 358 path = path[:-4] | |
| 359 | |
| 360 return path in OK_REPOS[host] | |
| 361 | |
| 362 | |
| 363 class NumberedBranch(collections.namedtuple('NumberedBranch', 'num')): | |
| 364 # pylint: disable=W0232 | |
| 365 @property | |
| 366 def remote_full_ref(self): | |
| 367 return 'refs/branch-heads/%d' % self.num | |
| 368 | |
| 369 @property | |
| 370 def local_full_ref(self): | |
| 371 return 'refs/origin/branch-heads/%d' % self.num | |
| 372 | |
| 373 | |
| 374 def parse_opts(): | |
| 375 epilog = textwrap.dedent("""\ | |
| 376 REF in the above may take the form of: | |
| 377 DDDD - a numbered branch (i.e. refs/branch-heads/DDDD) | |
| 378 """) | |
| 379 | |
| 380 commit_re = re.compile('^[0-9a-fA-F]{40}$') | |
| 381 def commit_type(s): | |
| 382 if not commit_re.match(s): | |
| 383 raise argparse.ArgumentTypeError("%r is not a valid commit hash" % s) | |
| 384 return s | |
| 385 | |
| 386 def ref_type(s): | |
| 387 if not s: | |
| 388 raise argparse.ArgumentTypeError("Empty ref: %r" % s) | |
| 389 if not s.isdigit(): | |
|
agable
2014/04/28 21:38:22
Doesn't handle 1780_21
| |
| 390 raise argparse.ArgumentTypeError("Invalid ref: %r" % s) | |
| 391 return NumberedBranch(int(s)) | |
| 392 | |
| 393 parser = argparse.ArgumentParser( | |
| 394 description=__doc__, epilog=epilog, | |
| 395 formatter_class=argparse.RawDescriptionHelpFormatter | |
| 396 ) | |
| 397 | |
| 398 parser.add_argument('commit', nargs=1, metavar='HASH', | |
| 399 type=commit_type, help='commit hash to revert/merge') | |
| 400 | |
| 401 parser.add_argument('--prep_only', action='store_true', default=False, | |
| 402 help=( | |
| 403 'Prep and upload the CL (without sending mail) but ' | |
| 404 'don\'t push.')) | |
| 405 | |
| 406 parser.add_argument('--bug', metavar='NUM', action='append', dest='bugs', | |
| 407 help='optional bug number(s)') | |
| 408 | |
| 409 grp = parser.add_mutually_exclusive_group(required=True) | |
| 410 grp.add_argument('--merge_to', metavar='REF', type=ref_type, | |
| 411 help='branch to merge to') | |
| 412 grp.add_argument('--revert_from', metavar='REF', type=ref_type, | |
| 413 help='branch ref to revert from') | |
| 414 opts = parser.parse_args() | |
| 415 | |
| 416 # TODO(iannucci): Support multiple commits | |
| 417 opts.commits = opts.commit | |
| 418 del opts.commit | |
| 419 | |
| 420 if opts.merge_to: | |
| 421 opts.action = 'merge' | |
| 422 opts.ref = opts.merge_to | |
| 423 elif opts.revert_from: | |
| 424 opts.action = 'revert' | |
| 425 opts.ref = opts.revert_from | |
| 426 else: | |
| 427 parser.error("?confusion? must specify either revert_from or merge_to") | |
| 428 | |
| 429 del opts.merge_to | |
| 430 del opts.revert_from | |
| 431 | |
| 432 return opts | |
| 433 | |
| 434 | |
| 435 def main(): | |
| 436 opts = parse_opts() | |
| 437 | |
| 438 announce('Preparing working directory') | |
| 439 | |
| 440 correct_url = ensure_working_directory(opts.commits, opts.ref) | |
| 441 summarize_job(correct_url, opts.commits, opts.ref, opts.action) | |
| 442 confirm() | |
| 443 | |
| 444 announce('Checking out branches to %s changes' % opts.action) | |
| 445 | |
| 446 git.run('fetch', 'origin', | |
| 447 '%s:%s' % (opts.ref.remote_full_ref, opts.ref.local_full_ref)) | |
| 448 git.check('update-ref', '-d', 'refs/heads/__drover_base') | |
| 449 git.run('checkout', '-b', '__drover_base', opts.ref.local_full_ref, | |
| 450 stdout=None, stderr=None) | |
| 451 git.run('config', 'branch.__drover_base.remote', 'origin') | |
| 452 git.run('config', 'branch.__drover_base.merge', opts.ref.remote_full_ref) | |
| 453 git.check('update-ref', '-d', 'refs/heads/__drover_change') | |
| 454 git.run('checkout', '-t', '__drover_base', '-b', '__drover_change', | |
| 455 stdout=None, stderr=None) | |
| 456 | |
| 457 announce('Performing %s' % opts.action) | |
| 458 | |
| 459 # TODO(iannucci): support --signoff ? | |
| 460 authors = [] | |
| 461 for commit in opts.commits: | |
| 462 success = False | |
| 463 if opts.action == 'merge': | |
| 464 success = git.check('cherry-pick', '-x', commit, verbose=True, | |
| 465 stdout=None, stderr=None) | |
| 466 else: # revert | |
| 467 success = git.check('revert', '--no-edit', commit, verbose=True, | |
| 468 stdout=None, stderr=None) | |
| 469 if not success: | |
| 470 die("Aborting. Failed to %s.", opts.action) | |
| 471 | |
| 472 email = git.run('show', '--format=%ae', '-s') | |
| 473 # git-svn email addresses take the form of: | |
| 474 # user@domain.com@<svn id> | |
| 475 authors.append('@'.join(email.split('@', 2)[:2])) | |
| 476 | |
| 477 announce('Success! Uploading codereview...') | |
| 478 | |
| 479 if opts.prep_only: | |
| 480 print "Prep only mode, uploading CL but not sending mail." | |
| 481 mail = [] | |
| 482 else: | |
| 483 mail = ['--send-mail', '--reviewers=' + ','.join(authors)] | |
| 484 | |
| 485 args = [ | |
| 486 '-c', 'gitcl.remotebranch=__drover_base', | |
| 487 '-c', 'branch.__drover_change.base-url=%s' % correct_url, | |
| 488 'cl', 'upload', '--bypass-hooks' | |
| 489 ] + mail | |
| 490 | |
| 491 # TODO(iannucci): option to not bypass hooks? | |
| 492 git.check(*args, stdout=None, stderr=None, stdin=None) | |
| 493 | |
| 494 if opts.prep_only: | |
| 495 announce('Issue created. To push to the branch, run `git cl push`') | |
| 496 else: | |
| 497 announce('About to push! This will make the commit live.') | |
| 498 confirm(abort=('Issue has been created, ' | |
| 499 'but change was not pushed to the repo.')) | |
| 500 git.run('cl', 'push', '-f', '--bypass-hooks') | |
| 501 | |
| 502 return 0 | |
| 503 | |
| 504 | |
| 505 if __name__ == '__main__': | |
| 506 sys.exit(main()) | |
| OLD | NEW |