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 |