blob: 450b37973c0e0c702fe328e8f2759986b8b08fcf [file] [log] [blame]
[email protected]dfaecd22011-04-21 00:33:311# coding=utf8
[email protected]9799a072012-01-11 00:26:252# Copyright (c) 2012 The Chromium Authors. All rights reserved.
[email protected]dfaecd22011-04-21 00:33:313# 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
7Includes support for svn, git-svn and git.
8"""
9
[email protected]dfaecd22011-04-21 00:33:3110import ConfigParser
11import fnmatch
12import logging
13import os
14import re
[email protected]5e975632011-09-29 18:07:0615import shutil
[email protected]dfaecd22011-04-21 00:33:3116import subprocess
17import sys
18import tempfile
19
20import patch
21import scm
22import subprocess2
23
24
[email protected]9af0a112013-03-20 20:21:3525if sys.platform in ('cygwin', 'win32'):
26 # Disable timeouts on Windows since we can't have shells with timeouts.
27 GLOBAL_TIMEOUT = None
28 FETCH_TIMEOUT = None
29else:
30 # Default timeout of 15 minutes.
31 GLOBAL_TIMEOUT = 15*60
32 # Use a larger timeout for checkout since it can be a genuinely slower
33 # operation.
34 FETCH_TIMEOUT = 30*60
35
36
[email protected]dfaecd22011-04-21 00:33:3137def get_code_review_setting(path, key,
38 codereview_settings_file='codereview.settings'):
39 """Parses codereview.settings and return the value for the key if present.
40
41 Don't cache the values in case the file is changed."""
42 # TODO(maruel): Do not duplicate code.
43 settings = {}
44 try:
45 settings_file = open(os.path.join(path, codereview_settings_file), 'r')
46 try:
47 for line in settings_file.readlines():
48 if not line or line.startswith('#'):
49 continue
50 if not ':' in line:
51 # Invalid file.
52 return None
53 k, v = line.split(':', 1)
54 settings[k.strip()] = v.strip()
55 finally:
56 settings_file.close()
[email protected]004fb712011-06-21 20:02:1657 except IOError:
[email protected]dfaecd22011-04-21 00:33:3158 return None
59 return settings.get(key, None)
60
61
[email protected]4dd9f722012-10-01 16:23:0362def align_stdout(stdout):
63 """Returns the aligned output of multiple stdouts."""
64 output = ''
65 for item in stdout:
66 item = item.strip()
67 if not item:
68 continue
69 output += ''.join(' %s\n' % line for line in item.splitlines())
70 return output
71
72
[email protected]dfaecd22011-04-21 00:33:3173class PatchApplicationFailed(Exception):
74 """Patch failed to be applied."""
[email protected]34f68552012-05-09 19:18:3675 def __init__(self, p, status):
76 super(PatchApplicationFailed, self).__init__(p, status)
77 self.patch = p
[email protected]dfaecd22011-04-21 00:33:3178 self.status = status
79
[email protected]34f68552012-05-09 19:18:3680 @property
81 def filename(self):
82 if self.patch:
83 return self.patch.filename
84
85 def __str__(self):
86 out = []
87 if self.filename:
88 out.append('Failed to apply patch for %s:' % self.filename)
89 if self.status:
90 out.append(self.status)
[email protected]cb5667a2012-10-23 19:42:1091 if self.patch:
92 out.append('Patch: %s' % self.patch.dump())
[email protected]34f68552012-05-09 19:18:3693 return '\n'.join(out)
94
[email protected]dfaecd22011-04-21 00:33:3195
96class CheckoutBase(object):
97 # Set to None to have verbose output.
98 VOID = subprocess2.VOID
99
[email protected]6ed8b502011-06-12 01:05:35100 def __init__(self, root_dir, project_name, post_processors):
101 """
102 Args:
103 post_processor: list of lambda(checkout, patches) to call on each of the
104 modified files.
105 """
[email protected]a5129fb2011-06-20 18:36:25106 super(CheckoutBase, self).__init__()
[email protected]dfaecd22011-04-21 00:33:31107 self.root_dir = root_dir
108 self.project_name = project_name
[email protected]3cdb7f32011-05-05 16:37:24109 if self.project_name is None:
110 self.project_path = self.root_dir
111 else:
112 self.project_path = os.path.join(self.root_dir, self.project_name)
[email protected]dfaecd22011-04-21 00:33:31113 # Only used for logging purposes.
114 self._last_seen_revision = None
[email protected]a5129fb2011-06-20 18:36:25115 self.post_processors = post_processors
[email protected]dfaecd22011-04-21 00:33:31116 assert self.root_dir
[email protected]dfaecd22011-04-21 00:33:31117 assert self.project_path
[email protected]0aca0f92012-10-01 16:39:45118 assert os.path.isabs(self.project_path)
[email protected]dfaecd22011-04-21 00:33:31119
120 def get_settings(self, key):
121 return get_code_review_setting(self.project_path, key)
122
[email protected]51919772011-06-12 01:27:42123 def prepare(self, revision):
[email protected]dfaecd22011-04-21 00:33:31124 """Checks out a clean copy of the tree and removes any local modification.
125
126 This function shouldn't throw unless the remote repository is inaccessible,
127 there is no free disk space or hard issues like that.
[email protected]51919772011-06-12 01:27:42128
129 Args:
130 revision: The revision it should sync to, SCM specific.
[email protected]dfaecd22011-04-21 00:33:31131 """
132 raise NotImplementedError()
133
[email protected]4dd9f722012-10-01 16:23:03134 def apply_patch(self, patches, post_processors=None, verbose=False):
[email protected]dfaecd22011-04-21 00:33:31135 """Applies a patch and returns the list of modified files.
136
137 This function should throw patch.UnsupportedPatchFormat or
138 PatchApplicationFailed when relevant.
[email protected]8a1396c2011-04-22 00:14:24139
140 Args:
141 patches: patch.PatchSet object.
[email protected]dfaecd22011-04-21 00:33:31142 """
143 raise NotImplementedError()
144
145 def commit(self, commit_message, user):
146 """Commits the patch upstream, while impersonating 'user'."""
147 raise NotImplementedError()
148
[email protected]bc32ad12012-07-26 13:22:47149 def revisions(self, rev1, rev2):
150 """Returns the count of revisions from rev1 to rev2, e.g. len(]rev1, rev2]).
151
152 If rev2 is None, it means 'HEAD'.
153
154 Returns None if there is no link between the two.
155 """
156 raise NotImplementedError()
157
[email protected]dfaecd22011-04-21 00:33:31158
159class RawCheckout(CheckoutBase):
160 """Used to apply a patch locally without any intent to commit it.
161
162 To be used by the try server.
163 """
[email protected]51919772011-06-12 01:27:42164 def prepare(self, revision):
[email protected]dfaecd22011-04-21 00:33:31165 """Stubbed out."""
166 pass
167
[email protected]4dd9f722012-10-01 16:23:03168 def apply_patch(self, patches, post_processors=None, verbose=False):
[email protected]8a1396c2011-04-22 00:14:24169 """Ignores svn properties."""
[email protected]b1d1a782011-09-29 14:13:55170 post_processors = post_processors or self.post_processors or []
[email protected]dfaecd22011-04-21 00:33:31171 for p in patches:
[email protected]4dd9f722012-10-01 16:23:03172 stdout = []
[email protected]dfaecd22011-04-21 00:33:31173 try:
[email protected]4dd9f722012-10-01 16:23:03174 filepath = os.path.join(self.project_path, p.filename)
[email protected]dfaecd22011-04-21 00:33:31175 if p.is_delete:
[email protected]4dd9f722012-10-01 16:23:03176 os.remove(filepath)
177 stdout.append('Deleted.')
[email protected]dfaecd22011-04-21 00:33:31178 else:
179 dirname = os.path.dirname(p.filename)
180 full_dir = os.path.join(self.project_path, dirname)
181 if dirname and not os.path.isdir(full_dir):
182 os.makedirs(full_dir)
[email protected]4dd9f722012-10-01 16:23:03183 stdout.append('Created missing directory %s.' % dirname)
[email protected]4869bcf2011-06-04 01:14:32184
[email protected]dfaecd22011-04-21 00:33:31185 if p.is_binary:
[email protected]4dd9f722012-10-01 16:23:03186 content = p.get()
[email protected]4869bcf2011-06-04 01:14:32187 with open(filepath, 'wb') as f:
[email protected]4dd9f722012-10-01 16:23:03188 f.write(content)
189 stdout.append('Added binary file %d bytes.' % len(content))
[email protected]dfaecd22011-04-21 00:33:31190 else:
[email protected]5e975632011-09-29 18:07:06191 if p.source_filename:
192 if not p.is_new:
193 raise PatchApplicationFailed(
[email protected]34f68552012-05-09 19:18:36194 p,
[email protected]5e975632011-09-29 18:07:06195 'File has a source filename specified but is not new')
196 # Copy the file first.
197 if os.path.isfile(filepath):
198 raise PatchApplicationFailed(
[email protected]34f68552012-05-09 19:18:36199 p, 'File exist but was about to be overwriten')
[email protected]5e975632011-09-29 18:07:06200 shutil.copy2(
201 os.path.join(self.project_path, p.source_filename), filepath)
[email protected]4dd9f722012-10-01 16:23:03202 stdout.append('Copied %s -> %s' % (p.source_filename, p.filename))
[email protected]58fe6622011-06-03 20:59:27203 if p.diff_hunks:
[email protected]4dd9f722012-10-01 16:23:03204 cmd = ['patch', '-u', '--binary', '-p%s' % p.patchlevel]
205 if verbose:
206 cmd.append('--verbose')
[email protected]23279942013-07-12 19:32:33207 env = os.environ.copy()
208 env['TMPDIR'] = tempfile.mkdtemp(prefix='crpatch')
209 try:
210 stdout.append(
211 subprocess2.check_output(
212 cmd,
213 stdin=p.get(False),
214 stderr=subprocess2.STDOUT,
215 cwd=self.project_path,
216 timeout=GLOBAL_TIMEOUT,
217 env=env))
218 finally:
219 shutil.rmtree(env['TMPDIR'])
[email protected]4869bcf2011-06-04 01:14:32220 elif p.is_new and not os.path.exists(filepath):
[email protected]58fe6622011-06-03 20:59:27221 # There is only a header. Just create the file.
[email protected]4869bcf2011-06-04 01:14:32222 open(filepath, 'w').close()
[email protected]4dd9f722012-10-01 16:23:03223 stdout.append('Created an empty file.')
[email protected]b1d1a782011-09-29 14:13:55224 for post in post_processors:
[email protected]8a1396c2011-04-22 00:14:24225 post(self, p)
[email protected]4dd9f722012-10-01 16:23:03226 if verbose:
227 print p.filename
228 print align_stdout(stdout)
[email protected]dfaecd22011-04-21 00:33:31229 except OSError, e:
[email protected]4dd9f722012-10-01 16:23:03230 raise PatchApplicationFailed(p, '%s%s' % (align_stdout(stdout), e))
[email protected]dfaecd22011-04-21 00:33:31231 except subprocess.CalledProcessError, e:
232 raise PatchApplicationFailed(
[email protected]4dd9f722012-10-01 16:23:03233 p,
234 'While running %s;\n%s%s' % (
235 ' '.join(e.cmd),
236 align_stdout(stdout),
237 align_stdout([getattr(e, 'stdout', '')])))
[email protected]dfaecd22011-04-21 00:33:31238
239 def commit(self, commit_message, user):
240 """Stubbed out."""
241 raise NotImplementedError('RawCheckout can\'t commit')
242
[email protected]bc32ad12012-07-26 13:22:47243 def revisions(self, _rev1, _rev2):
244 return None
245
[email protected]dfaecd22011-04-21 00:33:31246
247class SvnConfig(object):
248 """Parses a svn configuration file."""
249 def __init__(self, svn_config_dir=None):
[email protected]a5129fb2011-06-20 18:36:25250 super(SvnConfig, self).__init__()
[email protected]dfaecd22011-04-21 00:33:31251 self.svn_config_dir = svn_config_dir
252 self.default = not bool(self.svn_config_dir)
253 if not self.svn_config_dir:
254 if sys.platform == 'win32':
255 self.svn_config_dir = os.path.join(os.environ['APPDATA'], 'Subversion')
256 else:
257 self.svn_config_dir = os.path.join(os.environ['HOME'], '.subversion')
258 svn_config_file = os.path.join(self.svn_config_dir, 'config')
259 parser = ConfigParser.SafeConfigParser()
260 if os.path.isfile(svn_config_file):
261 parser.read(svn_config_file)
262 else:
263 parser.add_section('auto-props')
264 self.auto_props = dict(parser.items('auto-props'))
265
266
267class SvnMixIn(object):
268 """MixIn class to add svn commands common to both svn and git-svn clients."""
269 # These members need to be set by the subclass.
270 commit_user = None
271 commit_pwd = None
272 svn_url = None
273 project_path = None
274 # Override at class level when necessary. If used, --non-interactive is
275 # implied.
276 svn_config = SvnConfig()
277 # Set to True when non-interactivity is necessary but a custom subversion
278 # configuration directory is not necessary.
279 non_interactive = False
280
[email protected]9842a0c2011-05-30 20:41:54281 def _add_svn_flags(self, args, non_interactive, credentials=True):
[email protected]dfaecd22011-04-21 00:33:31282 args = ['svn'] + args
283 if not self.svn_config.default:
284 args.extend(['--config-dir', self.svn_config.svn_config_dir])
285 if not self.svn_config.default or self.non_interactive or non_interactive:
286 args.append('--non-interactive')
[email protected]9842a0c2011-05-30 20:41:54287 if credentials:
288 if self.commit_user:
289 args.extend(['--username', self.commit_user])
290 if self.commit_pwd:
291 args.extend(['--password', self.commit_pwd])
[email protected]dfaecd22011-04-21 00:33:31292 return args
293
294 def _check_call_svn(self, args, **kwargs):
295 """Runs svn and throws an exception if the command failed."""
296 kwargs.setdefault('cwd', self.project_path)
297 kwargs.setdefault('stdout', self.VOID)
[email protected]9af0a112013-03-20 20:21:35298 kwargs.setdefault('timeout', GLOBAL_TIMEOUT)
[email protected]0bcd1d32011-04-26 15:55:49299 return subprocess2.check_call_out(
[email protected]44b21b92012-11-08 19:37:08300 self._add_svn_flags(args, False), **kwargs)
[email protected]dfaecd22011-04-21 00:33:31301
[email protected]9842a0c2011-05-30 20:41:54302 def _check_output_svn(self, args, credentials=True, **kwargs):
[email protected]dfaecd22011-04-21 00:33:31303 """Runs svn and throws an exception if the command failed.
304
305 Returns the output.
306 """
307 kwargs.setdefault('cwd', self.project_path)
[email protected]9842a0c2011-05-30 20:41:54308 return subprocess2.check_output(
[email protected]87e6d332011-09-09 19:01:28309 self._add_svn_flags(args, True, credentials),
310 stderr=subprocess2.STDOUT,
[email protected]9af0a112013-03-20 20:21:35311 timeout=GLOBAL_TIMEOUT,
[email protected]87e6d332011-09-09 19:01:28312 **kwargs)
[email protected]dfaecd22011-04-21 00:33:31313
314 @staticmethod
315 def _parse_svn_info(output, key):
316 """Returns value for key from svn info output.
317
318 Case insensitive.
319 """
320 values = {}
321 key = key.lower()
322 for line in output.splitlines(False):
323 if not line:
324 continue
325 k, v = line.split(':', 1)
326 k = k.strip().lower()
327 v = v.strip()
328 assert not k in values
329 values[k] = v
330 return values.get(key, None)
331
332
333class SvnCheckout(CheckoutBase, SvnMixIn):
334 """Manages a subversion checkout."""
[email protected]6ed8b502011-06-12 01:05:35335 def __init__(self, root_dir, project_name, commit_user, commit_pwd, svn_url,
336 post_processors=None):
[email protected]a5129fb2011-06-20 18:36:25337 CheckoutBase.__init__(self, root_dir, project_name, post_processors)
338 SvnMixIn.__init__(self)
[email protected]dfaecd22011-04-21 00:33:31339 self.commit_user = commit_user
340 self.commit_pwd = commit_pwd
341 self.svn_url = svn_url
342 assert bool(self.commit_user) >= bool(self.commit_pwd)
[email protected]dfaecd22011-04-21 00:33:31343
[email protected]51919772011-06-12 01:27:42344 def prepare(self, revision):
[email protected]dfaecd22011-04-21 00:33:31345 # Will checkout if the directory is not present.
[email protected]3cdb7f32011-05-05 16:37:24346 assert self.svn_url
[email protected]dfaecd22011-04-21 00:33:31347 if not os.path.isdir(self.project_path):
348 logging.info('Checking out %s in %s' %
349 (self.project_name, self.project_path))
[email protected]51919772011-06-12 01:27:42350 return self._revert(revision)
[email protected]dfaecd22011-04-21 00:33:31351
[email protected]4dd9f722012-10-01 16:23:03352 def apply_patch(self, patches, post_processors=None, verbose=False):
[email protected]b1d1a782011-09-29 14:13:55353 post_processors = post_processors or self.post_processors or []
[email protected]dfaecd22011-04-21 00:33:31354 for p in patches:
[email protected]4dd9f722012-10-01 16:23:03355 stdout = []
[email protected]dfaecd22011-04-21 00:33:31356 try:
[email protected]4dd9f722012-10-01 16:23:03357 filepath = os.path.join(self.project_path, p.filename)
[email protected]9842a0c2011-05-30 20:41:54358 # It is important to use credentials=False otherwise credentials could
359 # leak in the error message. Credentials are not necessary here for the
360 # following commands anyway.
[email protected]dfaecd22011-04-21 00:33:31361 if p.is_delete:
[email protected]4dd9f722012-10-01 16:23:03362 stdout.append(self._check_output_svn(
363 ['delete', p.filename, '--force'], credentials=False))
364 stdout.append('Deleted.')
[email protected]dfaecd22011-04-21 00:33:31365 else:
[email protected]dfaecd22011-04-21 00:33:31366 # svn add while creating directories otherwise svn add on the
367 # contained files will silently fail.
368 # First, find the root directory that exists.
369 dirname = os.path.dirname(p.filename)
370 dirs_to_create = []
371 while (dirname and
372 not os.path.isdir(os.path.join(self.project_path, dirname))):
373 dirs_to_create.append(dirname)
374 dirname = os.path.dirname(dirname)
375 for dir_to_create in reversed(dirs_to_create):
376 os.mkdir(os.path.join(self.project_path, dir_to_create))
[email protected]4dd9f722012-10-01 16:23:03377 stdout.append(
378 self._check_output_svn(
379 ['add', dir_to_create, '--force'], credentials=False))
380 stdout.append('Created missing directory %s.' % dir_to_create)
[email protected]dfaecd22011-04-21 00:33:31381
382 if p.is_binary:
[email protected]4dd9f722012-10-01 16:23:03383 content = p.get()
[email protected]4869bcf2011-06-04 01:14:32384 with open(filepath, 'wb') as f:
[email protected]4dd9f722012-10-01 16:23:03385 f.write(content)
386 stdout.append('Added binary file %d bytes.' % len(content))
[email protected]dfaecd22011-04-21 00:33:31387 else:
[email protected]5e975632011-09-29 18:07:06388 if p.source_filename:
389 if not p.is_new:
390 raise PatchApplicationFailed(
[email protected]34f68552012-05-09 19:18:36391 p,
[email protected]5e975632011-09-29 18:07:06392 'File has a source filename specified but is not new')
393 # Copy the file first.
394 if os.path.isfile(filepath):
395 raise PatchApplicationFailed(
[email protected]34f68552012-05-09 19:18:36396 p, 'File exist but was about to be overwriten')
[email protected]4dd9f722012-10-01 16:23:03397 stdout.append(
398 self._check_output_svn(
399 ['copy', p.source_filename, p.filename]))
400 stdout.append('Copied %s -> %s' % (p.source_filename, p.filename))
[email protected]58fe6622011-06-03 20:59:27401 if p.diff_hunks:
[email protected]ec4a9182012-09-28 20:39:45402 cmd = [
403 'patch',
404 '-p%s' % p.patchlevel,
405 '--forward',
406 '--force',
407 '--no-backup-if-mismatch',
408 ]
[email protected]23279942013-07-12 19:32:33409 env = os.environ.copy()
410 env['TMPDIR'] = tempfile.mkdtemp(prefix='crpatch')
411 try:
412 stdout.append(
413 subprocess2.check_output(
414 cmd,
415 stdin=p.get(False),
416 cwd=self.project_path,
417 timeout=GLOBAL_TIMEOUT,
418 env=env))
419 finally:
420 shutil.rmtree(env['TMPDIR'])
421
[email protected]4869bcf2011-06-04 01:14:32422 elif p.is_new and not os.path.exists(filepath):
423 # There is only a header. Just create the file if it doesn't
424 # exist.
425 open(filepath, 'w').close()
[email protected]4dd9f722012-10-01 16:23:03426 stdout.append('Created an empty file.')
[email protected]3da83172012-05-07 16:17:20427 if p.is_new and not p.source_filename:
428 # Do not run it if p.source_filename is defined, since svn copy was
429 # using above.
[email protected]4dd9f722012-10-01 16:23:03430 stdout.append(
431 self._check_output_svn(
432 ['add', p.filename, '--force'], credentials=False))
[email protected]d7ca6162012-08-29 17:22:22433 for name, value in p.svn_properties:
434 if value is None:
[email protected]4dd9f722012-10-01 16:23:03435 stdout.append(
436 self._check_output_svn(
437 ['propdel', '--quiet', name, p.filename],
438 credentials=False))
439 stdout.append('Property %s deleted.' % name)
[email protected]d7ca6162012-08-29 17:22:22440 else:
[email protected]4dd9f722012-10-01 16:23:03441 stdout.append(
442 self._check_output_svn(
443 ['propset', name, value, p.filename], credentials=False))
444 stdout.append('Property %s=%s' % (name, value))
[email protected]9842a0c2011-05-30 20:41:54445 for prop, values in self.svn_config.auto_props.iteritems():
[email protected]dfaecd22011-04-21 00:33:31446 if fnmatch.fnmatch(p.filename, prop):
[email protected]9842a0c2011-05-30 20:41:54447 for value in values.split(';'):
448 if '=' not in value:
[email protected]e1a03762012-09-24 15:28:52449 params = [value, '.']
[email protected]9842a0c2011-05-30 20:41:54450 else:
451 params = value.split('=', 1)
[email protected]e1a03762012-09-24 15:28:52452 if params[1] == '*':
453 # Works around crbug.com/150960 on Windows.
454 params[1] = '.'
[email protected]4dd9f722012-10-01 16:23:03455 stdout.append(
456 self._check_output_svn(
457 ['propset'] + params + [p.filename], credentials=False))
458 stdout.append('Property (auto) %s' % '='.join(params))
[email protected]b1d1a782011-09-29 14:13:55459 for post in post_processors:
[email protected]8a1396c2011-04-22 00:14:24460 post(self, p)
[email protected]4dd9f722012-10-01 16:23:03461 if verbose:
462 print p.filename
463 print align_stdout(stdout)
[email protected]dfaecd22011-04-21 00:33:31464 except OSError, e:
[email protected]4dd9f722012-10-01 16:23:03465 raise PatchApplicationFailed(p, '%s%s' % (align_stdout(stdout), e))
[email protected]dfaecd22011-04-21 00:33:31466 except subprocess.CalledProcessError, e:
467 raise PatchApplicationFailed(
[email protected]34f68552012-05-09 19:18:36468 p,
[email protected]9842a0c2011-05-30 20:41:54469 'While running %s;\n%s%s' % (
[email protected]4dd9f722012-10-01 16:23:03470 ' '.join(e.cmd),
471 align_stdout(stdout),
472 align_stdout([getattr(e, 'stdout', '')])))
[email protected]dfaecd22011-04-21 00:33:31473
474 def commit(self, commit_message, user):
475 logging.info('Committing patch for %s' % user)
476 assert self.commit_user
[email protected]1bf50972011-05-05 19:57:21477 assert isinstance(commit_message, unicode)
[email protected]dfaecd22011-04-21 00:33:31478 handle, commit_filename = tempfile.mkstemp(text=True)
479 try:
[email protected]1bf50972011-05-05 19:57:21480 # Shouldn't assume default encoding is UTF-8. But really, if you are using
481 # anything else, you are living in another world.
482 os.write(handle, commit_message.encode('utf-8'))
[email protected]dfaecd22011-04-21 00:33:31483 os.close(handle)
484 # When committing, svn won't update the Revision metadata of the checkout,
485 # so if svn commit returns "Committed revision 3.", svn info will still
486 # return "Revision: 2". Since running svn update right after svn commit
487 # creates a race condition with other committers, this code _must_ parse
488 # the output of svn commit and use a regexp to grab the revision number.
489 # Note that "Committed revision N." is localized but subprocess2 forces
490 # LANGUAGE=en.
491 args = ['commit', '--file', commit_filename]
492 # realauthor is parsed by a server-side hook.
493 if user and user != self.commit_user:
494 args.extend(['--with-revprop', 'realauthor=%s' % user])
495 out = self._check_output_svn(args)
496 finally:
497 os.remove(commit_filename)
498 lines = filter(None, out.splitlines())
499 match = re.match(r'^Committed revision (\d+).$', lines[-1])
500 if not match:
501 raise PatchApplicationFailed(
502 None,
503 'Couldn\'t make sense out of svn commit message:\n' + out)
504 return int(match.group(1))
505
[email protected]51919772011-06-12 01:27:42506 def _revert(self, revision):
[email protected]dfaecd22011-04-21 00:33:31507 """Reverts local modifications or checks out if the directory is not
508 present. Use depot_tools's functionality to do this.
509 """
510 flags = ['--ignore-externals']
[email protected]51919772011-06-12 01:27:42511 if revision:
512 flags.extend(['--revision', str(revision)])
[email protected]6e904b42012-12-19 14:21:14513 if os.path.isdir(self.project_path):
514 # This may remove any part (or all) of the checkout.
515 scm.SVN.Revert(self.project_path, no_ignore=True)
516
517 if os.path.isdir(self.project_path):
518 # Revive files that were deleted in scm.SVN.Revert().
[email protected]9af0a112013-03-20 20:21:35519 self._check_call_svn(['update', '--force'] + flags,
520 timeout=FETCH_TIMEOUT)
[email protected]6e904b42012-12-19 14:21:14521 else:
[email protected]dfaecd22011-04-21 00:33:31522 logging.info(
523 'Directory %s is not present, checking it out.' % self.project_path)
524 self._check_call_svn(
[email protected]9af0a112013-03-20 20:21:35525 ['checkout', self.svn_url, self.project_path] + flags, cwd=None,
526 timeout=FETCH_TIMEOUT)
[email protected]51919772011-06-12 01:27:42527 return self._get_revision()
[email protected]dfaecd22011-04-21 00:33:31528
[email protected]51919772011-06-12 01:27:42529 def _get_revision(self):
[email protected]dfaecd22011-04-21 00:33:31530 out = self._check_output_svn(['info', '.'])
[email protected]51919772011-06-12 01:27:42531 revision = int(self._parse_svn_info(out, 'revision'))
532 if revision != self._last_seen_revision:
533 logging.info('Updated to revision %d' % revision)
534 self._last_seen_revision = revision
535 return revision
[email protected]dfaecd22011-04-21 00:33:31536
[email protected]bc32ad12012-07-26 13:22:47537 def revisions(self, rev1, rev2):
538 """Returns the number of actual commits, not just the difference between
539 numbers.
540 """
541 rev2 = rev2 or 'HEAD'
542 # Revision range is inclusive and ordering doesn't matter, they'll appear in
543 # the order specified.
544 try:
545 out = self._check_output_svn(
546 ['log', '-q', self.svn_url, '-r', '%s:%s' % (rev1, rev2)])
547 except subprocess.CalledProcessError:
548 return None
549 # Ignore the '----' lines.
550 return len([l for l in out.splitlines() if l.startswith('r')]) - 1
551
[email protected]dfaecd22011-04-21 00:33:31552
[email protected]3b5efdf2013-09-05 11:59:40553class GitCheckout(CheckoutBase):
554 """Manages a git checkout."""
555 def __init__(self, root_dir, project_name, remote_branch, git_url,
556 commit_user, post_processors=None):
[email protected]3b5efdf2013-09-05 11:59:40557 super(GitCheckout, self).__init__(root_dir, project_name, post_processors)
558 self.git_url = git_url
559 self.commit_user = commit_user
[email protected]dfaecd22011-04-21 00:33:31560 self.remote_branch = remote_branch
[email protected]3b5efdf2013-09-05 11:59:40561 # The working branch where patches will be applied. It will track the
562 # remote branch.
[email protected]dfaecd22011-04-21 00:33:31563 self.working_branch = 'working_branch'
[email protected]3b5efdf2013-09-05 11:59:40564 # There is no reason to not hardcode origin.
565 self.remote = 'origin'
[email protected]bb050f62013-10-03 16:53:54566 # There is no reason to not hardcode master.
567 self.master_branch = 'master'
[email protected]dfaecd22011-04-21 00:33:31568
[email protected]51919772011-06-12 01:27:42569 def prepare(self, revision):
[email protected]dfaecd22011-04-21 00:33:31570 """Resets the git repository in a clean state.
571
572 Checks it out if not present and deletes the working branch.
573 """
[email protected]3cdb7f32011-05-05 16:37:24574 assert self.remote_branch
[email protected]bb050f62013-10-03 16:53:54575 assert self.git_url
[email protected]3b5efdf2013-09-05 11:59:40576
577 if not os.path.isdir(self.project_path):
578 # Clone the repo if the directory is not present.
579 logging.info(
580 'Checking out %s in %s', self.project_name, self.project_path)
581 self._check_call_git(
582 ['clone', self.git_url, '-b', self.remote_branch, self.project_path],
583 cwd=None, timeout=FETCH_TIMEOUT)
584 else:
585 # Throw away all uncommitted changes in the existing checkout.
586 self._check_call_git(['checkout', self.remote_branch])
587 self._check_call_git(
588 ['reset', '--hard', '--quiet',
589 '%s/%s' % (self.remote, self.remote_branch)])
590
[email protected]51919772011-06-12 01:27:42591 if revision:
592 try:
[email protected]3b5efdf2013-09-05 11:59:40593 # Look if the commit hash already exist. If so, we can skip a
594 # 'git fetch' call.
[email protected]51919772011-06-12 01:27:42595 revision = self._check_output_git(['rev-parse', revision])
596 except subprocess.CalledProcessError:
[email protected]44b21b92012-11-08 19:37:08597 self._check_call_git(
598 ['fetch', self.remote, self.remote_branch, '--quiet'])
[email protected]51919772011-06-12 01:27:42599 revision = self._check_output_git(['rev-parse', revision])
600 self._check_call_git(['checkout', '--force', '--quiet', revision])
601 else:
602 branches, active = self._branches()
[email protected]bb050f62013-10-03 16:53:54603 if active != self.master_branch:
604 self._check_call_git(
605 ['checkout', '--force', '--quiet', self.master_branch])
[email protected]3b5efdf2013-09-05 11:59:40606 self._sync_remote_branch()
607
[email protected]51919772011-06-12 01:27:42608 if self.working_branch in branches:
609 self._call_git(['branch', '-D', self.working_branch])
[email protected]3b5efdf2013-09-05 11:59:40610 return self._get_head_commit_hash()
611
612 def _sync_remote_branch(self):
613 """Syncs the remote branch."""
614 # We do a 'git pull origin master:refs/remotes/origin/master' instead of
615 # 'git pull origin master' because from the manpage for git-pull:
616 # A parameter <ref> without a colon is equivalent to <ref>: when
617 # pulling/fetching, so it merges <ref> into the current branch without
618 # storing the remote branch anywhere locally.
619 remote_tracked_path = 'refs/remotes/%s/%s' % (
620 self.remote, self.remote_branch)
621 self._check_call_git(
622 ['pull', self.remote,
623 '%s:%s' % (self.remote_branch, remote_tracked_path),
624 '--quiet'])
625
626 def _get_head_commit_hash(self):
[email protected]11145db2013-10-03 12:43:40627 """Gets the current revision (in unicode) from the local branch."""
628 return unicode(self._check_output_git(['rev-parse', 'HEAD']).strip())
[email protected]dfaecd22011-04-21 00:33:31629
[email protected]4dd9f722012-10-01 16:23:03630 def apply_patch(self, patches, post_processors=None, verbose=False):
[email protected]3b5efdf2013-09-05 11:59:40631 """Applies a patch on 'working_branch' and switches to it.
[email protected]8a1396c2011-04-22 00:14:24632
633 Also commits the changes on the local branch.
634
635 Ignores svn properties and raise an exception on unexpected ones.
636 """
[email protected]b1d1a782011-09-29 14:13:55637 post_processors = post_processors or self.post_processors or []
[email protected]dfaecd22011-04-21 00:33:31638 # It this throws, the checkout is corrupted. Maybe worth deleting it and
639 # trying again?
[email protected]3cdb7f32011-05-05 16:37:24640 if self.remote_branch:
641 self._check_call_git(
[email protected]3b5efdf2013-09-05 11:59:40642 ['checkout', '-b', self.working_branch, '-t', self.remote_branch,
643 '--quiet'])
644
[email protected]5e975632011-09-29 18:07:06645 for index, p in enumerate(patches):
[email protected]4dd9f722012-10-01 16:23:03646 stdout = []
[email protected]dfaecd22011-04-21 00:33:31647 try:
[email protected]4dd9f722012-10-01 16:23:03648 filepath = os.path.join(self.project_path, p.filename)
[email protected]dfaecd22011-04-21 00:33:31649 if p.is_delete:
[email protected]4dd9f722012-10-01 16:23:03650 if (not os.path.exists(filepath) and
[email protected]5e975632011-09-29 18:07:06651 any(p1.source_filename == p.filename for p1 in patches[0:index])):
[email protected]4dd9f722012-10-01 16:23:03652 # The file was already deleted if a prior patch with file rename
653 # was already processed because 'git apply' did it for us.
[email protected]5e975632011-09-29 18:07:06654 pass
655 else:
[email protected]4dd9f722012-10-01 16:23:03656 stdout.append(self._check_output_git(['rm', p.filename]))
657 stdout.append('Deleted.')
[email protected]dfaecd22011-04-21 00:33:31658 else:
659 dirname = os.path.dirname(p.filename)
660 full_dir = os.path.join(self.project_path, dirname)
661 if dirname and not os.path.isdir(full_dir):
662 os.makedirs(full_dir)
[email protected]4dd9f722012-10-01 16:23:03663 stdout.append('Created missing directory %s.' % dirname)
[email protected]dfaecd22011-04-21 00:33:31664 if p.is_binary:
[email protected]4dd9f722012-10-01 16:23:03665 content = p.get()
666 with open(filepath, 'wb') as f:
667 f.write(content)
668 stdout.append('Added binary file %d bytes' % len(content))
669 cmd = ['add', p.filename]
670 if verbose:
671 cmd.append('--verbose')
672 stdout.append(self._check_output_git(cmd))
[email protected]dfaecd22011-04-21 00:33:31673 else:
[email protected]58fe6622011-06-03 20:59:27674 # No need to do anything special with p.is_new or if not
675 # p.diff_hunks. git apply manages all that already.
[email protected]4dd9f722012-10-01 16:23:03676 cmd = ['apply', '--index', '-p%s' % p.patchlevel]
677 if verbose:
678 cmd.append('--verbose')
679 stdout.append(self._check_output_git(cmd, stdin=p.get(True)))
680 for name, value in p.svn_properties:
[email protected]dfaecd22011-04-21 00:33:31681 # Ignore some known auto-props flags through .subversion/config,
682 # bails out on the other ones.
683 # TODO(maruel): Read ~/.subversion/config and detect the rules that
684 # applies here to figure out if the property will be correctly
685 # handled.
[email protected]4dd9f722012-10-01 16:23:03686 stdout.append('Property %s=%s' % (name, value))
[email protected]d7ca6162012-08-29 17:22:22687 if not name in (
[email protected]9799a072012-01-11 00:26:25688 'svn:eol-style', 'svn:executable', 'svn:mime-type'):
[email protected]dfaecd22011-04-21 00:33:31689 raise patch.UnsupportedPatchFormat(
690 p.filename,
691 'Cannot apply svn property %s to file %s.' % (
[email protected]d7ca6162012-08-29 17:22:22692 name, p.filename))
[email protected]b1d1a782011-09-29 14:13:55693 for post in post_processors:
[email protected]8a1396c2011-04-22 00:14:24694 post(self, p)
[email protected]4dd9f722012-10-01 16:23:03695 if verbose:
696 print p.filename
697 print align_stdout(stdout)
[email protected]dfaecd22011-04-21 00:33:31698 except OSError, e:
[email protected]4dd9f722012-10-01 16:23:03699 raise PatchApplicationFailed(p, '%s%s' % (align_stdout(stdout), e))
[email protected]dfaecd22011-04-21 00:33:31700 except subprocess.CalledProcessError, e:
701 raise PatchApplicationFailed(
[email protected]4dd9f722012-10-01 16:23:03702 p,
703 'While running %s;\n%s%s' % (
704 ' '.join(e.cmd),
705 align_stdout(stdout),
706 align_stdout([getattr(e, 'stdout', '')])))
[email protected]dfaecd22011-04-21 00:33:31707 # Once all the patches are processed and added to the index, commit the
708 # index.
[email protected]4dd9f722012-10-01 16:23:03709 cmd = ['commit', '-m', 'Committed patch']
710 if verbose:
711 cmd.append('--verbose')
712 self._check_call_git(cmd)
[email protected]3b5efdf2013-09-05 11:59:40713 found_files = self._check_output_git(
[email protected]bb050f62013-10-03 16:53:54714 ['diff', '%s/%s' % (self.remote,
715 self.remote_branch or self.master_branch),
[email protected]3b5efdf2013-09-05 11:59:40716 '--name-only']).splitlines(False)
717 assert sorted(patches.filenames) == sorted(found_files), (
718 sorted(patches.filenames), sorted(found_files))
[email protected]dfaecd22011-04-21 00:33:31719
720 def commit(self, commit_message, user):
[email protected]3b5efdf2013-09-05 11:59:40721 """Commits, updates the commit message and pushes."""
[email protected]bb050f62013-10-03 16:53:54722 assert self.commit_user
[email protected]1bf50972011-05-05 19:57:21723 assert isinstance(commit_message, unicode)
[email protected]3b5efdf2013-09-05 11:59:40724 current_branch = self._check_output_git(
725 ['rev-parse', '--abbrev-ref', 'HEAD']).strip()
726 assert current_branch == self.working_branch
727
728 commit_cmd = ['commit', '--amend', '-m', commit_message]
729 if user and user != self.commit_user:
730 # We do not have the first or last name of the user, grab the username
731 # from the email and call it the original author's name.
732 # TODO(rmistry): Do not need the below if user is already in
733 # "Name <email>" format.
734 name = user.split('@')[0]
735 commit_cmd.extend(['--author', '%s <%s>' % (name, user)])
736 self._check_call_git(commit_cmd)
737
738 # Push to the remote repository.
739 self._check_call_git(
740 ['push', 'origin', '%s:%s' % (self.working_branch, self.remote_branch),
741 '--force', '--quiet'])
742 # Get the revision after the push.
743 revision = self._get_head_commit_hash()
744 # Switch back to the remote_branch and sync it.
745 self._check_call_git(['checkout', self.remote_branch])
746 self._sync_remote_branch()
747 # Delete the working branch since we are done with it.
748 self._check_call_git(['branch', '-D', self.working_branch])
749
750 return revision
[email protected]dfaecd22011-04-21 00:33:31751
752 def _check_call_git(self, args, **kwargs):
753 kwargs.setdefault('cwd', self.project_path)
754 kwargs.setdefault('stdout', self.VOID)
[email protected]9af0a112013-03-20 20:21:35755 kwargs.setdefault('timeout', GLOBAL_TIMEOUT)
[email protected]44b21b92012-11-08 19:37:08756 return subprocess2.check_call_out(['git'] + args, **kwargs)
[email protected]dfaecd22011-04-21 00:33:31757
758 def _call_git(self, args, **kwargs):
759 """Like check_call but doesn't throw on failure."""
760 kwargs.setdefault('cwd', self.project_path)
761 kwargs.setdefault('stdout', self.VOID)
[email protected]9af0a112013-03-20 20:21:35762 kwargs.setdefault('timeout', GLOBAL_TIMEOUT)
[email protected]44b21b92012-11-08 19:37:08763 return subprocess2.call(['git'] + args, **kwargs)
[email protected]dfaecd22011-04-21 00:33:31764
765 def _check_output_git(self, args, **kwargs):
766 kwargs.setdefault('cwd', self.project_path)
[email protected]9af0a112013-03-20 20:21:35767 kwargs.setdefault('timeout', GLOBAL_TIMEOUT)
[email protected]87e6d332011-09-09 19:01:28768 return subprocess2.check_output(
[email protected]44b21b92012-11-08 19:37:08769 ['git'] + args, stderr=subprocess2.STDOUT, **kwargs)
[email protected]dfaecd22011-04-21 00:33:31770
771 def _branches(self):
772 """Returns the list of branches and the active one."""
773 out = self._check_output_git(['branch']).splitlines(False)
774 branches = [l[2:] for l in out]
775 active = None
776 for l in out:
777 if l.startswith('*'):
778 active = l[2:]
779 break
780 return branches, active
781
[email protected]bc32ad12012-07-26 13:22:47782 def revisions(self, rev1, rev2):
783 """Returns the number of actual commits between both hash."""
784 self._fetch_remote()
785
786 rev2 = rev2 or '%s/%s' % (self.remote, self.remote_branch)
787 # Revision range is ]rev1, rev2] and ordering matters.
788 try:
789 out = self._check_output_git(
790 ['log', '--format="%H"' , '%s..%s' % (rev1, rev2)])
791 except subprocess.CalledProcessError:
792 return None
793 return len(out.splitlines())
794
795 def _fetch_remote(self):
796 """Fetches the remote without rebasing."""
[email protected]3b5efdf2013-09-05 11:59:40797 # git fetch is always verbose even with -q, so redirect its output.
[email protected]9af0a112013-03-20 20:21:35798 self._check_output_git(['fetch', self.remote, self.remote_branch],
799 timeout=FETCH_TIMEOUT)
[email protected]bc32ad12012-07-26 13:22:47800
[email protected]dfaecd22011-04-21 00:33:31801
[email protected]dfaecd22011-04-21 00:33:31802class ReadOnlyCheckout(object):
803 """Converts a checkout into a read-only one."""
[email protected]b1d1a782011-09-29 14:13:55804 def __init__(self, checkout, post_processors=None):
[email protected]a5129fb2011-06-20 18:36:25805 super(ReadOnlyCheckout, self).__init__()
[email protected]dfaecd22011-04-21 00:33:31806 self.checkout = checkout
[email protected]b1d1a782011-09-29 14:13:55807 self.post_processors = (post_processors or []) + (
808 self.checkout.post_processors or [])
[email protected]dfaecd22011-04-21 00:33:31809
[email protected]51919772011-06-12 01:27:42810 def prepare(self, revision):
811 return self.checkout.prepare(revision)
[email protected]dfaecd22011-04-21 00:33:31812
813 def get_settings(self, key):
814 return self.checkout.get_settings(key)
815
[email protected]4dd9f722012-10-01 16:23:03816 def apply_patch(self, patches, post_processors=None, verbose=False):
[email protected]b1d1a782011-09-29 14:13:55817 return self.checkout.apply_patch(
[email protected]4dd9f722012-10-01 16:23:03818 patches, post_processors or self.post_processors, verbose)
[email protected]dfaecd22011-04-21 00:33:31819
820 def commit(self, message, user): # pylint: disable=R0201
821 logging.info('Would have committed for %s with message: %s' % (
822 user, message))
823 return 'FAKE'
824
[email protected]bc32ad12012-07-26 13:22:47825 def revisions(self, rev1, rev2):
826 return self.checkout.revisions(rev1, rev2)
827
[email protected]dfaecd22011-04-21 00:33:31828 @property
829 def project_name(self):
830 return self.checkout.project_name
831
832 @property
833 def project_path(self):
834 return self.checkout.project_path