Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(1281)

Side by Side Diff: git_drover.py

Issue 240203005: Implement git-drover. (Closed) Base URL: svn://svn.chromium.org/chrome/trunk/tools/depot_tools
Patch Set: address feedback Created 6 years, 7 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch | Annotate | Revision Log
« no previous file with comments | « git_common.py ('k') | no next file » | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
(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())
OLDNEW
« no previous file with comments | « git_common.py ('k') | no next file » | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698