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

Side by Side Diff: checkout.py

Issue 6877055: Move commit-queue/checkout into depot_tools so it can be reused by the try server. (Closed) Base URL: svn://svn.chromium.org/chrome/trunk/tools/depot_tools
Patch Set: .gitignore Created 9 years, 8 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 | « .gitignore ('k') | tests/checkout_test.py » ('j') | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
(Empty)
1 # coding=utf8
2 # Copyright (c) 2011 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 """Manages a project checkout.
6
7 Includes support for svn, git-svn and git.
8 """
9
10 from __future__ import with_statement
11 import ConfigParser
12 import fnmatch
13 import logging
14 import os
15 import re
16 import subprocess
17 import sys
18 import tempfile
19
20 import patch
21 import scm
22 import subprocess2
23
24
25 def get_code_review_setting(path, key,
26 codereview_settings_file='codereview.settings'):
27 """Parses codereview.settings and return the value for the key if present.
28
29 Don't cache the values in case the file is changed."""
30 # TODO(maruel): Do not duplicate code.
31 settings = {}
32 try:
33 settings_file = open(os.path.join(path, codereview_settings_file), 'r')
34 try:
35 for line in settings_file.readlines():
36 if not line or line.startswith('#'):
37 continue
38 if not ':' in line:
39 # Invalid file.
40 return None
41 k, v = line.split(':', 1)
42 settings[k.strip()] = v.strip()
43 finally:
44 settings_file.close()
45 except OSError:
46 return None
47 return settings.get(key, None)
48
49
50 class PatchApplicationFailed(Exception):
51 """Patch failed to be applied."""
52 def __init__(self, filename, status):
53 super(PatchApplicationFailed, self).__init__(filename, status)
54 self.filename = filename
55 self.status = status
56
57
58 class CheckoutBase(object):
59 # Set to None to have verbose output.
60 VOID = subprocess2.VOID
61
62 def __init__(self, root_dir, project_name):
63 self.root_dir = root_dir
64 self.project_name = project_name
65 self.project_path = os.path.join(self.root_dir, self.project_name)
66 # Only used for logging purposes.
67 self._last_seen_revision = None
68 assert self.root_dir
69 assert self.project_name
70 assert self.project_path
71
72 def get_settings(self, key):
73 return get_code_review_setting(self.project_path, key)
74
75 def prepare(self):
76 """Checks out a clean copy of the tree and removes any local modification.
77
78 This function shouldn't throw unless the remote repository is inaccessible,
79 there is no free disk space or hard issues like that.
80 """
81 raise NotImplementedError()
82
83 def apply_patch(self, patches):
84 """Applies a patch and returns the list of modified files.
85
86 This function should throw patch.UnsupportedPatchFormat or
87 PatchApplicationFailed when relevant.
88 """
89 raise NotImplementedError()
90
91 def commit(self, commit_message, user):
92 """Commits the patch upstream, while impersonating 'user'."""
93 raise NotImplementedError()
94
95
96 class RawCheckout(CheckoutBase):
97 """Used to apply a patch locally without any intent to commit it.
98
99 To be used by the try server.
100 """
101 def prepare(self):
102 """Stubbed out."""
103 pass
104
105 def apply_patch(self, patches):
106 for p in patches:
107 try:
108 stdout = ''
109 filename = os.path.join(self.project_path, p.filename)
110 if p.is_delete:
111 os.remove(filename)
112 else:
113 dirname = os.path.dirname(p.filename)
114 full_dir = os.path.join(self.project_path, dirname)
115 if dirname and not os.path.isdir(full_dir):
116 os.makedirs(full_dir)
117 if p.is_binary:
118 with open(os.path.join(filename), 'wb') as f:
119 f.write(p.get())
120 else:
121 stdout = subprocess2.check_output(
122 ['patch', '-p%s' % p.patchlevel],
123 stdin=p.get(),
124 cwd=self.project_path)
125 # Ignore p.svn_properties.
126 except OSError, e:
127 raise PatchApplicationFailed(p.filename, '%s%s' % (stdout, e))
128 except subprocess.CalledProcessError, e:
129 raise PatchApplicationFailed(
130 p.filename, '%s%s' % (stdout, getattr(e, 'stdout', None)))
131
132 def commit(self, commit_message, user):
133 """Stubbed out."""
134 raise NotImplementedError('RawCheckout can\'t commit')
135
136
137 class SvnConfig(object):
138 """Parses a svn configuration file."""
139 def __init__(self, svn_config_dir=None):
140 self.svn_config_dir = svn_config_dir
141 self.default = not bool(self.svn_config_dir)
142 if not self.svn_config_dir:
143 if sys.platform == 'win32':
144 self.svn_config_dir = os.path.join(os.environ['APPDATA'], 'Subversion')
145 else:
146 self.svn_config_dir = os.path.join(os.environ['HOME'], '.subversion')
147 svn_config_file = os.path.join(self.svn_config_dir, 'config')
148 parser = ConfigParser.SafeConfigParser()
149 if os.path.isfile(svn_config_file):
150 parser.read(svn_config_file)
151 else:
152 parser.add_section('auto-props')
153 self.auto_props = dict(parser.items('auto-props'))
154
155
156 class SvnMixIn(object):
157 """MixIn class to add svn commands common to both svn and git-svn clients."""
158 # These members need to be set by the subclass.
159 commit_user = None
160 commit_pwd = None
161 svn_url = None
162 project_path = None
163 # Override at class level when necessary. If used, --non-interactive is
164 # implied.
165 svn_config = SvnConfig()
166 # Set to True when non-interactivity is necessary but a custom subversion
167 # configuration directory is not necessary.
168 non_interactive = False
169
170 def _add_svn_flags(self, args, non_interactive):
171 args = ['svn'] + args
172 if not self.svn_config.default:
173 args.extend(['--config-dir', self.svn_config.svn_config_dir])
174 if not self.svn_config.default or self.non_interactive or non_interactive:
175 args.append('--non-interactive')
176 if self.commit_user:
177 args.extend(['--username', self.commit_user])
178 if self.commit_pwd:
179 args.extend(['--password', self.commit_pwd])
180 return args
181
182 def _check_call_svn(self, args, **kwargs):
183 """Runs svn and throws an exception if the command failed."""
184 kwargs.setdefault('cwd', self.project_path)
185 kwargs.setdefault('stdout', self.VOID)
186 return subprocess2.check_call(self._add_svn_flags(args, False), **kwargs)
187
188 def _check_output_svn(self, args, **kwargs):
189 """Runs svn and throws an exception if the command failed.
190
191 Returns the output.
192 """
193 kwargs.setdefault('cwd', self.project_path)
194 return subprocess2.check_output(self._add_svn_flags(args, True), **kwargs)
195
196 @staticmethod
197 def _parse_svn_info(output, key):
198 """Returns value for key from svn info output.
199
200 Case insensitive.
201 """
202 values = {}
203 key = key.lower()
204 for line in output.splitlines(False):
205 if not line:
206 continue
207 k, v = line.split(':', 1)
208 k = k.strip().lower()
209 v = v.strip()
210 assert not k in values
211 values[k] = v
212 return values.get(key, None)
213
214
215 class SvnCheckout(CheckoutBase, SvnMixIn):
216 """Manages a subversion checkout."""
217 def __init__(self, root_dir, project_name, commit_user, commit_pwd, svn_url):
218 super(SvnCheckout, self).__init__(root_dir, project_name)
219 self.commit_user = commit_user
220 self.commit_pwd = commit_pwd
221 self.svn_url = svn_url
222 assert bool(self.commit_user) >= bool(self.commit_pwd)
223 assert self.svn_url
224
225 def prepare(self):
226 """Creates the initial checkouts for the repo."""
227 # Will checkout if the directory is not present.
228 if not os.path.isdir(self.project_path):
229 logging.info('Checking out %s in %s' %
230 (self.project_name, self.project_path))
231 revision = self._revert()
232 if revision != self._last_seen_revision:
233 logging.info('Updated at revision %d' % revision)
234 self._last_seen_revision = revision
235 return revision
236
237 def apply_patch(self, patches):
238 """Applies a patch."""
239 for p in patches:
240 try:
241 stdout = ''
242 if p.is_delete:
243 stdout += self._check_output_svn(['delete', p.filename, '--force'])
244 else:
245 new = not os.path.exists(p.filename)
246
247 # svn add while creating directories otherwise svn add on the
248 # contained files will silently fail.
249 # First, find the root directory that exists.
250 dirname = os.path.dirname(p.filename)
251 dirs_to_create = []
252 while (dirname and
253 not os.path.isdir(os.path.join(self.project_path, dirname))):
254 dirs_to_create.append(dirname)
255 dirname = os.path.dirname(dirname)
256 for dir_to_create in reversed(dirs_to_create):
257 os.mkdir(os.path.join(self.project_path, dir_to_create))
258 stdout += self._check_output_svn(
259 ['add', dir_to_create, '--force'])
260
261 if p.is_binary:
262 with open(os.path.join(self.project_path, p.filename), 'wb') as f:
263 f.write(p.get())
264 else:
265 cmd = ['patch', '-p%s' % p.patchlevel, '--forward', '--force']
266 stdout += subprocess2.check_output(
267 cmd, stdin=p.get(), cwd=self.project_path)
268 if new:
269 stdout += self._check_output_svn(['add', p.filename, '--force'])
270 for prop in p.svn_properties:
271 stdout += self._check_output_svn(
272 ['propset', prop[0], prop[1], p.filename])
273 for prop, value in self.svn_config.auto_props.iteritems():
274 if fnmatch.fnmatch(p.filename, prop):
275 stdout += self._check_output_svn(
276 ['propset'] + value.split('=', 1) + [p.filename])
277 except OSError, e:
278 raise PatchApplicationFailed(p.filename, '%s%s' % (stdout, e))
279 except subprocess.CalledProcessError, e:
280 raise PatchApplicationFailed(
281 p.filename, '%s%s' % (stdout, getattr(e, 'stdout', '')))
282
283 def commit(self, commit_message, user):
284 logging.info('Committing patch for %s' % user)
285 assert self.commit_user
286 handle, commit_filename = tempfile.mkstemp(text=True)
287 try:
288 os.write(handle, commit_message)
289 os.close(handle)
290 # When committing, svn won't update the Revision metadata of the checkout,
291 # so if svn commit returns "Committed revision 3.", svn info will still
292 # return "Revision: 2". Since running svn update right after svn commit
293 # creates a race condition with other committers, this code _must_ parse
294 # the output of svn commit and use a regexp to grab the revision number.
295 # Note that "Committed revision N." is localized but subprocess2 forces
296 # LANGUAGE=en.
297 args = ['commit', '--file', commit_filename]
298 # realauthor is parsed by a server-side hook.
299 if user and user != self.commit_user:
300 args.extend(['--with-revprop', 'realauthor=%s' % user])
301 out = self._check_output_svn(args)
302 finally:
303 os.remove(commit_filename)
304 lines = filter(None, out.splitlines())
305 match = re.match(r'^Committed revision (\d+).$', lines[-1])
306 if not match:
307 raise PatchApplicationFailed(
308 None,
309 'Couldn\'t make sense out of svn commit message:\n' + out)
310 return int(match.group(1))
311
312 def _revert(self):
313 """Reverts local modifications or checks out if the directory is not
314 present. Use depot_tools's functionality to do this.
315 """
316 flags = ['--ignore-externals']
317 if not os.path.isdir(self.project_path):
318 logging.info(
319 'Directory %s is not present, checking it out.' % self.project_path)
320 self._check_call_svn(
321 ['checkout', self.svn_url, self.project_path] + flags, cwd=None)
322 else:
323 scm.SVN.Revert(self.project_path)
324 # Revive files that were deleted in scm.SVN.Revert().
325 self._check_call_svn(['update', '--force'] + flags)
326
327 out = self._check_output_svn(['info', '.'])
328 return int(self._parse_svn_info(out, 'revision'))
329
330
331 class GitCheckoutBase(CheckoutBase):
332 """Base class for git checkout. Not to be used as-is."""
333 def __init__(self, root_dir, project_name, remote_branch):
334 super(GitCheckoutBase, self).__init__(root_dir, project_name)
335 # There is no reason to not hardcode it.
336 self.remote = 'origin'
337 self.remote_branch = remote_branch
338 self.working_branch = 'working_branch'
339 assert self.remote_branch
340
341 def prepare(self):
342 """Resets the git repository in a clean state.
343
344 Checks it out if not present and deletes the working branch.
345 """
346 assert os.path.isdir(self.project_path)
347 self._check_call_git(['reset', '--hard', '--quiet'])
348 branches, active = self._branches()
349 if active != 'master':
350 self._check_call_git(['checkout', 'master', '--force', '--quiet'])
351 self._check_call_git(['pull', self.remote, self.remote_branch, '--quiet'])
352 if self.working_branch in branches:
353 self._call_git(['branch', '-D', self.working_branch])
354
355 def apply_patch(self, patches):
356 """Applies a patch on 'working_branch' and switch to it."""
357 # It this throws, the checkout is corrupted. Maybe worth deleting it and
358 # trying again?
359 self._check_call_git(
360 ['checkout', '-b', self.working_branch,
361 '%s/%s' % (self.remote, self.remote_branch)])
362 for p in patches:
363 try:
364 stdout = ''
365 if p.is_delete:
366 stdout += self._check_output_git(['rm', p.filename])
367 else:
368 dirname = os.path.dirname(p.filename)
369 full_dir = os.path.join(self.project_path, dirname)
370 if dirname and not os.path.isdir(full_dir):
371 os.makedirs(full_dir)
372 if p.is_binary:
373 with open(os.path.join(self.project_path, p.filename), 'wb') as f:
374 f.write(p.get())
375 stdout += self._check_output_git(['add', p.filename])
376 else:
377 stdout += self._check_output_git(
378 ['apply', '--index', '-p%s' % p.patchlevel], stdin=p.get())
379 for prop in p.svn_properties:
380 # Ignore some known auto-props flags through .subversion/config,
381 # bails out on the other ones.
382 # TODO(maruel): Read ~/.subversion/config and detect the rules that
383 # applies here to figure out if the property will be correctly
384 # handled.
385 if not prop[0] in ('svn:eol-style', 'svn:executable'):
386 raise patch.UnsupportedPatchFormat(
387 p.filename,
388 'Cannot apply svn property %s to file %s.' % (
389 prop[0], p.filename))
390 except OSError, e:
391 raise PatchApplicationFailed(p.filename, '%s%s' % (stdout, e))
392 except subprocess.CalledProcessError, e:
393 raise PatchApplicationFailed(
394 p.filename, '%s%s' % (stdout, getattr(e, 'stdout', None)))
395 # Once all the patches are processed and added to the index, commit the
396 # index.
397 self._check_call_git(['commit', '-m', 'Committed patch'])
398 # TODO(maruel): Weirdly enough they don't match, need to investigate.
399 #found_files = self._check_output_git(
400 # ['diff', 'master', '--name-only']).splitlines(False)
401 #assert sorted(patches.filenames) == sorted(found_files), (
402 # sorted(out), sorted(found_files))
403
404 def commit(self, commit_message, user):
405 """Updates the commit message.
406
407 Subclass needs to dcommit or push.
408 """
409 self._check_call_git(['commit', '--amend', '-m', commit_message])
410 return self._check_output_git(['rev-parse', 'HEAD']).strip()
411
412 def _check_call_git(self, args, **kwargs):
413 kwargs.setdefault('cwd', self.project_path)
414 kwargs.setdefault('stdout', self.VOID)
415 return subprocess2.check_call(['git'] + args, **kwargs)
416
417 def _call_git(self, args, **kwargs):
418 """Like check_call but doesn't throw on failure."""
419 kwargs.setdefault('cwd', self.project_path)
420 kwargs.setdefault('stdout', self.VOID)
421 return subprocess2.call(['git'] + args, **kwargs)
422
423 def _check_output_git(self, args, **kwargs):
424 kwargs.setdefault('cwd', self.project_path)
425 return subprocess2.check_output(['git'] + args, **kwargs)
426
427 def _branches(self):
428 """Returns the list of branches and the active one."""
429 out = self._check_output_git(['branch']).splitlines(False)
430 branches = [l[2:] for l in out]
431 active = None
432 for l in out:
433 if l.startswith('*'):
434 active = l[2:]
435 break
436 return branches, active
437
438
439 class GitSvnCheckoutBase(GitCheckoutBase, SvnMixIn):
440 """Base class for git-svn checkout. Not to be used as-is."""
441 def __init__(self,
442 root_dir, project_name, remote_branch,
443 commit_user, commit_pwd,
444 svn_url, trunk):
445 """trunk is optional."""
446 super(GitSvnCheckoutBase, self).__init__(
447 root_dir, project_name + '.git', remote_branch)
448 self.commit_user = commit_user
449 self.commit_pwd = commit_pwd
450 # svn_url in this case is the root of the svn repository.
451 self.svn_url = svn_url
452 self.trunk = trunk
453 assert bool(self.commit_user) >= bool(self.commit_pwd)
454 assert self.svn_url
455 assert self.trunk
456 self._cache_svn_auth()
457
458 def prepare(self):
459 """Resets the git repository in a clean state."""
460 self._check_call_git(['reset', '--hard', '--quiet'])
461 branches, active = self._branches()
462 if active != 'master':
463 if not 'master' in branches:
464 self._check_call_git(
465 ['checkout', '--quiet', '-b', 'master',
466 '%s/%s' % (self.remote, self.remote_branch)])
467 else:
468 self._check_call_git(['checkout', 'master', '--force', '--quiet'])
469 # git svn rebase --quiet --quiet doesn't work, use two steps to silence it.
470 self._check_call_git_svn(['fetch', '--quiet', '--quiet'])
471 self._check_call_git(
472 ['rebase', '--quiet', '--quiet',
473 '%s/%s' % (self.remote, self.remote_branch)])
474 if self.working_branch in branches:
475 self._call_git(['branch', '-D', self.working_branch])
476 return int(self._git_svn_info('revision'))
477
478 def _git_svn_info(self, key):
479 """Calls git svn info. This doesn't support nor need --config-dir."""
480 return self._parse_svn_info(self._check_output_git(['svn', 'info']), key)
481
482 def commit(self, commit_message, user):
483 """Commits a patch."""
484 logging.info('Committing patch for %s' % user)
485 # Fix the commit message and author. It returns the git hash, which we
486 # ignore unless it's None.
487 if not super(GitSvnCheckoutBase, self).commit(commit_message, user):
488 return None
489 # TODO(maruel): git-svn ignores --config-dir as of git-svn version 1.7.4 and
490 # doesn't support --with-revprop.
491 # Either learn perl and upstream or suck it.
492 kwargs = {}
493 if self.commit_pwd:
494 kwargs['stdin'] = self.commit_pwd + '\n'
495 self._check_call_git_svn(
496 ['dcommit', '--rmdir', '--find-copies-harder',
497 '--username', self.commit_user],
498 **kwargs)
499 revision = int(self._git_svn_info('revision'))
500 return revision
501
502 def _cache_svn_auth(self):
503 """Caches the svn credentials. It is necessary since git-svn doesn't prompt
504 for it."""
505 if not self.commit_user or not self.commit_pwd:
506 return
507 # Use capture to lower noise in logs.
508 self._check_output_svn(['ls', self.svn_url], cwd=None)
509
510 def _check_call_git_svn(self, args, **kwargs):
511 """Handles svn authentication while calling git svn."""
512 args = ['svn'] + args
513 if not self.svn_config.default:
514 args.extend(['--config-dir', self.svn_config.svn_config_dir])
515 return self._check_call_git(args, **kwargs)
516
517 def _get_revision(self):
518 revision = int(self._git_svn_info('revision'))
519 if revision != self._last_seen_revision:
520 logging.info('Updated at revision %d' % revision)
521 self._last_seen_revision = revision
522 return revision
523
524
525 class GitSvnPremadeCheckout(GitSvnCheckoutBase):
526 """Manages a git-svn clone made out from an initial git-svn seed.
527
528 This class is very similar to GitSvnCheckout but is faster to bootstrap
529 because it starts right off with an existing git-svn clone.
530 """
531 def __init__(self,
532 root_dir, project_name, remote_branch,
533 commit_user, commit_pwd,
534 svn_url, trunk, git_url):
535 super(GitSvnPremadeCheckout, self).__init__(
536 root_dir, project_name, remote_branch,
537 commit_user, commit_pwd,
538 svn_url, trunk)
539 self.git_url = git_url
540 assert self.git_url
541
542 def prepare(self):
543 """Creates the initial checkout for the repo."""
544 if not os.path.isdir(self.project_path):
545 logging.info('Checking out %s in %s' %
546 (self.project_name, self.project_path))
547 assert self.remote == 'origin'
548 # self.project_path doesn't exist yet.
549 self._check_call_git(
550 ['clone', self.git_url, self.project_name],
551 cwd=self.root_dir)
552 try:
553 configured_svn_url = self._check_output_git(
554 ['config', 'svn-remote.svn.url']).strip()
555 except subprocess.CalledProcessError:
556 configured_svn_url = ''
557
558 if configured_svn_url.strip() != self.svn_url:
559 self._check_call_git_svn(
560 ['init',
561 '--prefix', self.remote + '/',
562 '-T', self.trunk,
563 self.svn_url])
564 self._check_call_git_svn(['fetch'])
565 super(GitSvnPremadeCheckout, self).prepare()
566 return self._get_revision()
567
568
569 class GitSvnCheckout(GitSvnCheckoutBase):
570 """Manages a git-svn clone.
571
572 Using git-svn hides some of the complexity of using a svn checkout.
573 """
574 def __init__(self,
575 root_dir, project_name,
576 commit_user, commit_pwd,
577 svn_url, trunk):
578 super(GitSvnCheckout, self).__init__(
579 root_dir, project_name, 'trunk',
580 commit_user, commit_pwd,
581 svn_url, trunk)
582
583 def prepare(self):
584 """Creates the initial checkout for the repo."""
585 if not os.path.isdir(self.project_path):
586 logging.info('Checking out %s in %s' %
587 (self.project_name, self.project_path))
588 # TODO: Create a shallow clone.
589 # self.project_path doesn't exist yet.
590 self._check_call_git_svn(
591 ['clone',
592 '--prefix', self.remote + '/',
593 '-T', self.trunk,
594 self.svn_url, self.project_path],
595 cwd=self.root_dir)
596 super(GitSvnCheckout, self).prepare()
597 return self._get_revision()
598
599
600 class ReadOnlyCheckout(object):
601 """Converts a checkout into a read-only one."""
602 def __init__(self, checkout):
603 self.checkout = checkout
604
605 def prepare(self):
606 return self.checkout.prepare()
607
608 def get_settings(self, key):
609 return self.checkout.get_settings(key)
610
611 def apply_patch(self, patches):
612 return self.checkout.apply_patch(patches)
613
614 def commit(self, message, user): # pylint: disable=R0201
615 logging.info('Would have committed for %s with message: %s' % (
616 user, message))
617 return 'FAKE'
618
619 @property
620 def project_name(self):
621 return self.checkout.project_name
622
623 @property
624 def project_path(self):
625 return self.checkout.project_path
OLDNEW
« no previous file with comments | « .gitignore ('k') | tests/checkout_test.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698