blob: da2d86f73a9ab4a40f9371faf894f043d4f9e492 [file] [log] [blame]
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +00001# coding=utf8
maruel@chromium.org9799a072012-01-11 00:26:25 +00002# Copyright (c) 2012 The Chromium Authors. All rights reserved.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +00003# 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
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000010import ConfigParser
11import fnmatch
12import logging
13import os
14import re
maruel@chromium.org5e975632011-09-29 18:07:06 +000015import shutil
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000016import subprocess
17import sys
18import tempfile
19
20import patch
21import scm
22import subprocess2
23
24
maruel@chromium.org83273542012-11-08 19:10:50 +000025# Default timeout of 15 minutes.
26GLOBAL_TIMEOUT = 15*60
27# Use a larger timeout for checkout since it can be a genuinely slower
28# operation.
29FETCH_TIMEOUT = 30*60
30
31
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000032def get_code_review_setting(path, key,
33 codereview_settings_file='codereview.settings'):
34 """Parses codereview.settings and return the value for the key if present.
35
36 Don't cache the values in case the file is changed."""
37 # TODO(maruel): Do not duplicate code.
38 settings = {}
39 try:
40 settings_file = open(os.path.join(path, codereview_settings_file), 'r')
41 try:
42 for line in settings_file.readlines():
43 if not line or line.startswith('#'):
44 continue
45 if not ':' in line:
46 # Invalid file.
47 return None
48 k, v = line.split(':', 1)
49 settings[k.strip()] = v.strip()
50 finally:
51 settings_file.close()
maruel@chromium.org004fb712011-06-21 20:02:16 +000052 except IOError:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000053 return None
54 return settings.get(key, None)
55
56
maruel@chromium.org4dd9f722012-10-01 16:23:03 +000057def align_stdout(stdout):
58 """Returns the aligned output of multiple stdouts."""
59 output = ''
60 for item in stdout:
61 item = item.strip()
62 if not item:
63 continue
64 output += ''.join(' %s\n' % line for line in item.splitlines())
65 return output
66
67
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000068class PatchApplicationFailed(Exception):
69 """Patch failed to be applied."""
maruel@chromium.org34f68552012-05-09 19:18:36 +000070 def __init__(self, p, status):
71 super(PatchApplicationFailed, self).__init__(p, status)
72 self.patch = p
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000073 self.status = status
74
maruel@chromium.org34f68552012-05-09 19:18:36 +000075 @property
76 def filename(self):
77 if self.patch:
78 return self.patch.filename
79
80 def __str__(self):
81 out = []
82 if self.filename:
83 out.append('Failed to apply patch for %s:' % self.filename)
84 if self.status:
85 out.append(self.status)
maruel@chromium.orgcb5667a2012-10-23 19:42:10 +000086 if self.patch:
87 out.append('Patch: %s' % self.patch.dump())
maruel@chromium.org34f68552012-05-09 19:18:36 +000088 return '\n'.join(out)
89
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000090
91class CheckoutBase(object):
92 # Set to None to have verbose output.
93 VOID = subprocess2.VOID
94
maruel@chromium.org6ed8b502011-06-12 01:05:35 +000095 def __init__(self, root_dir, project_name, post_processors):
96 """
97 Args:
98 post_processor: list of lambda(checkout, patches) to call on each of the
99 modified files.
100 """
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000101 super(CheckoutBase, self).__init__()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000102 self.root_dir = root_dir
103 self.project_name = project_name
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000104 if self.project_name is None:
105 self.project_path = self.root_dir
106 else:
107 self.project_path = os.path.join(self.root_dir, self.project_name)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000108 # Only used for logging purposes.
109 self._last_seen_revision = None
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000110 self.post_processors = post_processors
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000111 assert self.root_dir
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000112 assert self.project_path
maruel@chromium.org0aca0f92012-10-01 16:39:45 +0000113 assert os.path.isabs(self.project_path)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000114
115 def get_settings(self, key):
116 return get_code_review_setting(self.project_path, key)
117
maruel@chromium.org51919772011-06-12 01:27:42 +0000118 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000119 """Checks out a clean copy of the tree and removes any local modification.
120
121 This function shouldn't throw unless the remote repository is inaccessible,
122 there is no free disk space or hard issues like that.
maruel@chromium.org51919772011-06-12 01:27:42 +0000123
124 Args:
125 revision: The revision it should sync to, SCM specific.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000126 """
127 raise NotImplementedError()
128
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000129 def apply_patch(self, patches, post_processors=None, verbose=False):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000130 """Applies a patch and returns the list of modified files.
131
132 This function should throw patch.UnsupportedPatchFormat or
133 PatchApplicationFailed when relevant.
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000134
135 Args:
136 patches: patch.PatchSet object.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000137 """
138 raise NotImplementedError()
139
140 def commit(self, commit_message, user):
141 """Commits the patch upstream, while impersonating 'user'."""
142 raise NotImplementedError()
143
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000144 def revisions(self, rev1, rev2):
145 """Returns the count of revisions from rev1 to rev2, e.g. len(]rev1, rev2]).
146
147 If rev2 is None, it means 'HEAD'.
148
149 Returns None if there is no link between the two.
150 """
151 raise NotImplementedError()
152
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000153
154class RawCheckout(CheckoutBase):
155 """Used to apply a patch locally without any intent to commit it.
156
157 To be used by the try server.
158 """
maruel@chromium.org51919772011-06-12 01:27:42 +0000159 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000160 """Stubbed out."""
161 pass
162
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000163 def apply_patch(self, patches, post_processors=None, verbose=False):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000164 """Ignores svn properties."""
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000165 post_processors = post_processors or self.post_processors or []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000166 for p in patches:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000167 stdout = []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000168 try:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000169 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000170 if p.is_delete:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000171 os.remove(filepath)
172 stdout.append('Deleted.')
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000173 else:
174 dirname = os.path.dirname(p.filename)
175 full_dir = os.path.join(self.project_path, dirname)
176 if dirname and not os.path.isdir(full_dir):
177 os.makedirs(full_dir)
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000178 stdout.append('Created missing directory %s.' % dirname)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000179
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000180 if p.is_binary:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000181 content = p.get()
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000182 with open(filepath, 'wb') as f:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000183 f.write(content)
184 stdout.append('Added binary file %d bytes.' % len(content))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000185 else:
maruel@chromium.org5e975632011-09-29 18:07:06 +0000186 if p.source_filename:
187 if not p.is_new:
188 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000189 p,
maruel@chromium.org5e975632011-09-29 18:07:06 +0000190 'File has a source filename specified but is not new')
191 # Copy the file first.
192 if os.path.isfile(filepath):
193 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000194 p, 'File exist but was about to be overwriten')
maruel@chromium.org5e975632011-09-29 18:07:06 +0000195 shutil.copy2(
196 os.path.join(self.project_path, p.source_filename), filepath)
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000197 stdout.append('Copied %s -> %s' % (p.source_filename, p.filename))
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000198 if p.diff_hunks:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000199 cmd = ['patch', '-u', '--binary', '-p%s' % p.patchlevel]
200 if verbose:
201 cmd.append('--verbose')
202 stdout.append(
203 subprocess2.check_output(
204 cmd,
205 stdin=p.get(False),
206 stderr=subprocess2.STDOUT,
maruel@chromium.org83273542012-11-08 19:10:50 +0000207 cwd=self.project_path,
208 timeout=GLOBAL_TIMEOUT))
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000209 elif p.is_new and not os.path.exists(filepath):
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000210 # There is only a header. Just create the file.
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000211 open(filepath, 'w').close()
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000212 stdout.append('Created an empty file.')
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000213 for post in post_processors:
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000214 post(self, p)
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000215 if verbose:
216 print p.filename
217 print align_stdout(stdout)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000218 except OSError, e:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000219 raise PatchApplicationFailed(p, '%s%s' % (align_stdout(stdout), e))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000220 except subprocess.CalledProcessError, e:
221 raise PatchApplicationFailed(
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000222 p,
223 'While running %s;\n%s%s' % (
224 ' '.join(e.cmd),
225 align_stdout(stdout),
226 align_stdout([getattr(e, 'stdout', '')])))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000227
228 def commit(self, commit_message, user):
229 """Stubbed out."""
230 raise NotImplementedError('RawCheckout can\'t commit')
231
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000232 def revisions(self, _rev1, _rev2):
233 return None
234
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000235
236class SvnConfig(object):
237 """Parses a svn configuration file."""
238 def __init__(self, svn_config_dir=None):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000239 super(SvnConfig, self).__init__()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000240 self.svn_config_dir = svn_config_dir
241 self.default = not bool(self.svn_config_dir)
242 if not self.svn_config_dir:
243 if sys.platform == 'win32':
244 self.svn_config_dir = os.path.join(os.environ['APPDATA'], 'Subversion')
245 else:
246 self.svn_config_dir = os.path.join(os.environ['HOME'], '.subversion')
247 svn_config_file = os.path.join(self.svn_config_dir, 'config')
248 parser = ConfigParser.SafeConfigParser()
249 if os.path.isfile(svn_config_file):
250 parser.read(svn_config_file)
251 else:
252 parser.add_section('auto-props')
253 self.auto_props = dict(parser.items('auto-props'))
254
255
256class SvnMixIn(object):
257 """MixIn class to add svn commands common to both svn and git-svn clients."""
258 # These members need to be set by the subclass.
259 commit_user = None
260 commit_pwd = None
261 svn_url = None
262 project_path = None
263 # Override at class level when necessary. If used, --non-interactive is
264 # implied.
265 svn_config = SvnConfig()
266 # Set to True when non-interactivity is necessary but a custom subversion
267 # configuration directory is not necessary.
268 non_interactive = False
269
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000270 def _add_svn_flags(self, args, non_interactive, credentials=True):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000271 args = ['svn'] + args
272 if not self.svn_config.default:
273 args.extend(['--config-dir', self.svn_config.svn_config_dir])
274 if not self.svn_config.default or self.non_interactive or non_interactive:
275 args.append('--non-interactive')
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000276 if credentials:
277 if self.commit_user:
278 args.extend(['--username', self.commit_user])
279 if self.commit_pwd:
280 args.extend(['--password', self.commit_pwd])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000281 return args
282
283 def _check_call_svn(self, args, **kwargs):
284 """Runs svn and throws an exception if the command failed."""
285 kwargs.setdefault('cwd', self.project_path)
286 kwargs.setdefault('stdout', self.VOID)
maruel@chromium.org83273542012-11-08 19:10:50 +0000287 kwargs.setdefault('timeout', GLOBAL_TIMEOUT)
maruel@chromium.org0bcd1d32011-04-26 15:55:49 +0000288 return subprocess2.check_call_out(
maruel@chromium.org83273542012-11-08 19:10:50 +0000289 self._add_svn_flags(args, False),
290 **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000291
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000292 def _check_output_svn(self, args, credentials=True, **kwargs):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000293 """Runs svn and throws an exception if the command failed.
294
295 Returns the output.
296 """
297 kwargs.setdefault('cwd', self.project_path)
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000298 return subprocess2.check_output(
maruel@chromium.org87e6d332011-09-09 19:01:28 +0000299 self._add_svn_flags(args, True, credentials),
300 stderr=subprocess2.STDOUT,
maruel@chromium.org83273542012-11-08 19:10:50 +0000301 timeout=GLOBAL_TIMEOUT,
maruel@chromium.org87e6d332011-09-09 19:01:28 +0000302 **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000303
304 @staticmethod
305 def _parse_svn_info(output, key):
306 """Returns value for key from svn info output.
307
308 Case insensitive.
309 """
310 values = {}
311 key = key.lower()
312 for line in output.splitlines(False):
313 if not line:
314 continue
315 k, v = line.split(':', 1)
316 k = k.strip().lower()
317 v = v.strip()
318 assert not k in values
319 values[k] = v
320 return values.get(key, None)
321
322
323class SvnCheckout(CheckoutBase, SvnMixIn):
324 """Manages a subversion checkout."""
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000325 def __init__(self, root_dir, project_name, commit_user, commit_pwd, svn_url,
326 post_processors=None):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000327 CheckoutBase.__init__(self, root_dir, project_name, post_processors)
328 SvnMixIn.__init__(self)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000329 self.commit_user = commit_user
330 self.commit_pwd = commit_pwd
331 self.svn_url = svn_url
332 assert bool(self.commit_user) >= bool(self.commit_pwd)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000333
maruel@chromium.org51919772011-06-12 01:27:42 +0000334 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000335 # Will checkout if the directory is not present.
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000336 assert self.svn_url
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000337 if not os.path.isdir(self.project_path):
338 logging.info('Checking out %s in %s' %
339 (self.project_name, self.project_path))
maruel@chromium.org51919772011-06-12 01:27:42 +0000340 return self._revert(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000341
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000342 def apply_patch(self, patches, post_processors=None, verbose=False):
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000343 post_processors = post_processors or self.post_processors or []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000344 for p in patches:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000345 stdout = []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000346 try:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000347 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000348 # It is important to use credentials=False otherwise credentials could
349 # leak in the error message. Credentials are not necessary here for the
350 # following commands anyway.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000351 if p.is_delete:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000352 stdout.append(self._check_output_svn(
353 ['delete', p.filename, '--force'], credentials=False))
354 stdout.append('Deleted.')
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000355 else:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000356 # svn add while creating directories otherwise svn add on the
357 # contained files will silently fail.
358 # First, find the root directory that exists.
359 dirname = os.path.dirname(p.filename)
360 dirs_to_create = []
361 while (dirname and
362 not os.path.isdir(os.path.join(self.project_path, dirname))):
363 dirs_to_create.append(dirname)
364 dirname = os.path.dirname(dirname)
365 for dir_to_create in reversed(dirs_to_create):
366 os.mkdir(os.path.join(self.project_path, dir_to_create))
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000367 stdout.append(
368 self._check_output_svn(
369 ['add', dir_to_create, '--force'], credentials=False))
370 stdout.append('Created missing directory %s.' % dir_to_create)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000371
372 if p.is_binary:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000373 content = p.get()
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000374 with open(filepath, 'wb') as f:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000375 f.write(content)
376 stdout.append('Added binary file %d bytes.' % len(content))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000377 else:
maruel@chromium.org5e975632011-09-29 18:07:06 +0000378 if p.source_filename:
379 if not p.is_new:
380 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000381 p,
maruel@chromium.org5e975632011-09-29 18:07:06 +0000382 'File has a source filename specified but is not new')
383 # Copy the file first.
384 if os.path.isfile(filepath):
385 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000386 p, 'File exist but was about to be overwriten')
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000387 stdout.append(
388 self._check_output_svn(
389 ['copy', p.source_filename, p.filename]))
390 stdout.append('Copied %s -> %s' % (p.source_filename, p.filename))
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000391 if p.diff_hunks:
maruel@chromium.orgec4a9182012-09-28 20:39:45 +0000392 cmd = [
393 'patch',
394 '-p%s' % p.patchlevel,
395 '--forward',
396 '--force',
397 '--no-backup-if-mismatch',
398 ]
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000399 stdout.append(
400 subprocess2.check_output(
maruel@chromium.org83273542012-11-08 19:10:50 +0000401 cmd,
402 stdin=p.get(False),
403 cwd=self.project_path,
404 timeout=GLOBAL_TIMEOUT))
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000405 elif p.is_new and not os.path.exists(filepath):
406 # There is only a header. Just create the file if it doesn't
407 # exist.
408 open(filepath, 'w').close()
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000409 stdout.append('Created an empty file.')
maruel@chromium.org3da83172012-05-07 16:17:20 +0000410 if p.is_new and not p.source_filename:
411 # Do not run it if p.source_filename is defined, since svn copy was
412 # using above.
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000413 stdout.append(
414 self._check_output_svn(
415 ['add', p.filename, '--force'], credentials=False))
maruel@chromium.orgd7ca6162012-08-29 17:22:22 +0000416 for name, value in p.svn_properties:
417 if value is None:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000418 stdout.append(
419 self._check_output_svn(
420 ['propdel', '--quiet', name, p.filename],
421 credentials=False))
422 stdout.append('Property %s deleted.' % name)
maruel@chromium.orgd7ca6162012-08-29 17:22:22 +0000423 else:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000424 stdout.append(
425 self._check_output_svn(
426 ['propset', name, value, p.filename], credentials=False))
427 stdout.append('Property %s=%s' % (name, value))
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000428 for prop, values in self.svn_config.auto_props.iteritems():
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000429 if fnmatch.fnmatch(p.filename, prop):
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000430 for value in values.split(';'):
431 if '=' not in value:
maruel@chromium.orge1a03762012-09-24 15:28:52 +0000432 params = [value, '.']
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000433 else:
434 params = value.split('=', 1)
maruel@chromium.orge1a03762012-09-24 15:28:52 +0000435 if params[1] == '*':
436 # Works around crbug.com/150960 on Windows.
437 params[1] = '.'
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000438 stdout.append(
439 self._check_output_svn(
440 ['propset'] + params + [p.filename], credentials=False))
441 stdout.append('Property (auto) %s' % '='.join(params))
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000442 for post in post_processors:
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000443 post(self, p)
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000444 if verbose:
445 print p.filename
446 print align_stdout(stdout)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000447 except OSError, e:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000448 raise PatchApplicationFailed(p, '%s%s' % (align_stdout(stdout), e))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000449 except subprocess.CalledProcessError, e:
450 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000451 p,
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000452 'While running %s;\n%s%s' % (
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000453 ' '.join(e.cmd),
454 align_stdout(stdout),
455 align_stdout([getattr(e, 'stdout', '')])))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000456
457 def commit(self, commit_message, user):
458 logging.info('Committing patch for %s' % user)
459 assert self.commit_user
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000460 assert isinstance(commit_message, unicode)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000461 handle, commit_filename = tempfile.mkstemp(text=True)
462 try:
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000463 # Shouldn't assume default encoding is UTF-8. But really, if you are using
464 # anything else, you are living in another world.
465 os.write(handle, commit_message.encode('utf-8'))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000466 os.close(handle)
467 # When committing, svn won't update the Revision metadata of the checkout,
468 # so if svn commit returns "Committed revision 3.", svn info will still
469 # return "Revision: 2". Since running svn update right after svn commit
470 # creates a race condition with other committers, this code _must_ parse
471 # the output of svn commit and use a regexp to grab the revision number.
472 # Note that "Committed revision N." is localized but subprocess2 forces
473 # LANGUAGE=en.
474 args = ['commit', '--file', commit_filename]
475 # realauthor is parsed by a server-side hook.
476 if user and user != self.commit_user:
477 args.extend(['--with-revprop', 'realauthor=%s' % user])
478 out = self._check_output_svn(args)
479 finally:
480 os.remove(commit_filename)
481 lines = filter(None, out.splitlines())
482 match = re.match(r'^Committed revision (\d+).$', lines[-1])
483 if not match:
484 raise PatchApplicationFailed(
485 None,
486 'Couldn\'t make sense out of svn commit message:\n' + out)
487 return int(match.group(1))
488
maruel@chromium.org51919772011-06-12 01:27:42 +0000489 def _revert(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000490 """Reverts local modifications or checks out if the directory is not
491 present. Use depot_tools's functionality to do this.
492 """
493 flags = ['--ignore-externals']
maruel@chromium.org51919772011-06-12 01:27:42 +0000494 if revision:
495 flags.extend(['--revision', str(revision)])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000496 if not os.path.isdir(self.project_path):
497 logging.info(
498 'Directory %s is not present, checking it out.' % self.project_path)
499 self._check_call_svn(
maruel@chromium.org83273542012-11-08 19:10:50 +0000500 ['checkout', self.svn_url, self.project_path] + flags,
501 cwd=None,
502 timeout=FETCH_TIMEOUT)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000503 else:
maruel@chromium.org83273542012-11-08 19:10:50 +0000504 # TODO(maruel): This command will shell out without a timeout.
maruel@chromium.orgea15cb72012-05-04 14:16:31 +0000505 scm.SVN.Revert(self.project_path, no_ignore=True)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000506 # Revive files that were deleted in scm.SVN.Revert().
maruel@chromium.org83273542012-11-08 19:10:50 +0000507 self._check_call_svn(['update', '--force'] + flags, timeout=FETCH_TIMEOUT)
maruel@chromium.org51919772011-06-12 01:27:42 +0000508 return self._get_revision()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000509
maruel@chromium.org51919772011-06-12 01:27:42 +0000510 def _get_revision(self):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000511 out = self._check_output_svn(['info', '.'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000512 revision = int(self._parse_svn_info(out, 'revision'))
513 if revision != self._last_seen_revision:
514 logging.info('Updated to revision %d' % revision)
515 self._last_seen_revision = revision
516 return revision
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000517
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000518 def revisions(self, rev1, rev2):
519 """Returns the number of actual commits, not just the difference between
520 numbers.
521 """
522 rev2 = rev2 or 'HEAD'
523 # Revision range is inclusive and ordering doesn't matter, they'll appear in
524 # the order specified.
525 try:
526 out = self._check_output_svn(
527 ['log', '-q', self.svn_url, '-r', '%s:%s' % (rev1, rev2)])
528 except subprocess.CalledProcessError:
529 return None
530 # Ignore the '----' lines.
531 return len([l for l in out.splitlines() if l.startswith('r')]) - 1
532
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000533
534class GitCheckoutBase(CheckoutBase):
535 """Base class for git checkout. Not to be used as-is."""
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000536 def __init__(self, root_dir, project_name, remote_branch,
537 post_processors=None):
538 super(GitCheckoutBase, self).__init__(
539 root_dir, project_name, post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000540 # There is no reason to not hardcode it.
541 self.remote = 'origin'
542 self.remote_branch = remote_branch
543 self.working_branch = 'working_branch'
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000544
maruel@chromium.org51919772011-06-12 01:27:42 +0000545 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000546 """Resets the git repository in a clean state.
547
548 Checks it out if not present and deletes the working branch.
549 """
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000550 assert self.remote_branch
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000551 assert os.path.isdir(self.project_path)
552 self._check_call_git(['reset', '--hard', '--quiet'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000553 if revision:
554 try:
555 revision = self._check_output_git(['rev-parse', revision])
556 except subprocess.CalledProcessError:
maruel@chromium.org83273542012-11-08 19:10:50 +0000557 self._fetch_remote()
maruel@chromium.org51919772011-06-12 01:27:42 +0000558 revision = self._check_output_git(['rev-parse', revision])
559 self._check_call_git(['checkout', '--force', '--quiet', revision])
560 else:
561 branches, active = self._branches()
562 if active != 'master':
563 self._check_call_git(['checkout', '--force', '--quiet', 'master'])
564 self._check_call_git(['pull', self.remote, self.remote_branch, '--quiet'])
565 if self.working_branch in branches:
566 self._call_git(['branch', '-D', self.working_branch])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000567
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000568 def apply_patch(self, patches, post_processors=None, verbose=False):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000569 """Applies a patch on 'working_branch' and switch to it.
570
571 Also commits the changes on the local branch.
572
573 Ignores svn properties and raise an exception on unexpected ones.
574 """
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000575 post_processors = post_processors or self.post_processors or []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000576 # It this throws, the checkout is corrupted. Maybe worth deleting it and
577 # trying again?
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000578 if self.remote_branch:
579 self._check_call_git(
580 ['checkout', '-b', self.working_branch,
581 '%s/%s' % (self.remote, self.remote_branch), '--quiet'])
maruel@chromium.org5e975632011-09-29 18:07:06 +0000582 for index, p in enumerate(patches):
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000583 stdout = []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000584 try:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000585 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000586 if p.is_delete:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000587 if (not os.path.exists(filepath) and
maruel@chromium.org5e975632011-09-29 18:07:06 +0000588 any(p1.source_filename == p.filename for p1 in patches[0:index])):
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000589 # The file was already deleted if a prior patch with file rename
590 # was already processed because 'git apply' did it for us.
maruel@chromium.org5e975632011-09-29 18:07:06 +0000591 pass
592 else:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000593 stdout.append(self._check_output_git(['rm', p.filename]))
594 stdout.append('Deleted.')
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000595 else:
596 dirname = os.path.dirname(p.filename)
597 full_dir = os.path.join(self.project_path, dirname)
598 if dirname and not os.path.isdir(full_dir):
599 os.makedirs(full_dir)
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000600 stdout.append('Created missing directory %s.' % dirname)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000601 if p.is_binary:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000602 content = p.get()
603 with open(filepath, 'wb') as f:
604 f.write(content)
605 stdout.append('Added binary file %d bytes' % len(content))
606 cmd = ['add', p.filename]
607 if verbose:
608 cmd.append('--verbose')
609 stdout.append(self._check_output_git(cmd))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000610 else:
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000611 # No need to do anything special with p.is_new or if not
612 # p.diff_hunks. git apply manages all that already.
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000613 cmd = ['apply', '--index', '-p%s' % p.patchlevel]
614 if verbose:
615 cmd.append('--verbose')
616 stdout.append(self._check_output_git(cmd, stdin=p.get(True)))
617 for name, value in p.svn_properties:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000618 # Ignore some known auto-props flags through .subversion/config,
619 # bails out on the other ones.
620 # TODO(maruel): Read ~/.subversion/config and detect the rules that
621 # applies here to figure out if the property will be correctly
622 # handled.
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000623 stdout.append('Property %s=%s' % (name, value))
maruel@chromium.orgd7ca6162012-08-29 17:22:22 +0000624 if not name in (
maruel@chromium.org9799a072012-01-11 00:26:25 +0000625 'svn:eol-style', 'svn:executable', 'svn:mime-type'):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000626 raise patch.UnsupportedPatchFormat(
627 p.filename,
628 'Cannot apply svn property %s to file %s.' % (
maruel@chromium.orgd7ca6162012-08-29 17:22:22 +0000629 name, p.filename))
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000630 for post in post_processors:
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000631 post(self, p)
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000632 if verbose:
633 print p.filename
634 print align_stdout(stdout)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000635 except OSError, e:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000636 raise PatchApplicationFailed(p, '%s%s' % (align_stdout(stdout), e))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000637 except subprocess.CalledProcessError, e:
638 raise PatchApplicationFailed(
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000639 p,
640 'While running %s;\n%s%s' % (
641 ' '.join(e.cmd),
642 align_stdout(stdout),
643 align_stdout([getattr(e, 'stdout', '')])))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000644 # Once all the patches are processed and added to the index, commit the
645 # index.
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000646 cmd = ['commit', '-m', 'Committed patch']
647 if verbose:
648 cmd.append('--verbose')
649 self._check_call_git(cmd)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000650 # TODO(maruel): Weirdly enough they don't match, need to investigate.
651 #found_files = self._check_output_git(
652 # ['diff', 'master', '--name-only']).splitlines(False)
653 #assert sorted(patches.filenames) == sorted(found_files), (
654 # sorted(out), sorted(found_files))
655
656 def commit(self, commit_message, user):
657 """Updates the commit message.
658
659 Subclass needs to dcommit or push.
660 """
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000661 assert isinstance(commit_message, unicode)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000662 self._check_call_git(['commit', '--amend', '-m', commit_message])
663 return self._check_output_git(['rev-parse', 'HEAD']).strip()
664
665 def _check_call_git(self, args, **kwargs):
666 kwargs.setdefault('cwd', self.project_path)
667 kwargs.setdefault('stdout', self.VOID)
maruel@chromium.org83273542012-11-08 19:10:50 +0000668 kwargs.setdefault('timeout', GLOBAL_TIMEOUT)
669 return subprocess2.check_call_out(
670 ['git'] + args, **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000671
672 def _call_git(self, args, **kwargs):
673 """Like check_call but doesn't throw on failure."""
674 kwargs.setdefault('cwd', self.project_path)
675 kwargs.setdefault('stdout', self.VOID)
maruel@chromium.org83273542012-11-08 19:10:50 +0000676 return subprocess2.call(['git'] + args, timeout=GLOBAL_TIMEOUT, **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000677
678 def _check_output_git(self, args, **kwargs):
679 kwargs.setdefault('cwd', self.project_path)
maruel@chromium.org87e6d332011-09-09 19:01:28 +0000680 return subprocess2.check_output(
maruel@chromium.org83273542012-11-08 19:10:50 +0000681 ['git'] + args,
682 stderr=subprocess2.STDOUT,
683 timeout=GLOBAL_TIMEOUT,
684 **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000685
686 def _branches(self):
687 """Returns the list of branches and the active one."""
688 out = self._check_output_git(['branch']).splitlines(False)
689 branches = [l[2:] for l in out]
690 active = None
691 for l in out:
692 if l.startswith('*'):
693 active = l[2:]
694 break
695 return branches, active
696
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000697 def revisions(self, rev1, rev2):
698 """Returns the number of actual commits between both hash."""
699 self._fetch_remote()
700
701 rev2 = rev2 or '%s/%s' % (self.remote, self.remote_branch)
702 # Revision range is ]rev1, rev2] and ordering matters.
703 try:
704 out = self._check_output_git(
705 ['log', '--format="%H"' , '%s..%s' % (rev1, rev2)])
706 except subprocess.CalledProcessError:
707 return None
708 return len(out.splitlines())
709
710 def _fetch_remote(self):
711 """Fetches the remote without rebasing."""
712 raise NotImplementedError()
713
714
715class GitCheckout(GitCheckoutBase):
716 """Git checkout implementation."""
717 def _fetch_remote(self):
718 # git fetch is always verbose even with -q -q so redirect its output.
maruel@chromium.org83273542012-11-08 19:10:50 +0000719 self._check_call_git(
720 ['fetch', self.remote, self.remote_branch, '--quiet'],
721 timeout=FETCH_TIMEOUT)
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000722
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000723
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000724class ReadOnlyCheckout(object):
725 """Converts a checkout into a read-only one."""
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000726 def __init__(self, checkout, post_processors=None):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000727 super(ReadOnlyCheckout, self).__init__()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000728 self.checkout = checkout
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000729 self.post_processors = (post_processors or []) + (
730 self.checkout.post_processors or [])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000731
maruel@chromium.org51919772011-06-12 01:27:42 +0000732 def prepare(self, revision):
733 return self.checkout.prepare(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000734
735 def get_settings(self, key):
736 return self.checkout.get_settings(key)
737
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000738 def apply_patch(self, patches, post_processors=None, verbose=False):
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000739 return self.checkout.apply_patch(
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000740 patches, post_processors or self.post_processors, verbose)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000741
742 def commit(self, message, user): # pylint: disable=R0201
743 logging.info('Would have committed for %s with message: %s' % (
744 user, message))
745 return 'FAKE'
746
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000747 def revisions(self, rev1, rev2):
748 return self.checkout.revisions(rev1, rev2)
749
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000750 @property
751 def project_name(self):
752 return self.checkout.project_name
753
754 @property
755 def project_path(self):
756 return self.checkout.project_path