blob: 62f04d42baee6356c745547c030feb3d956f80a3 [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
25def 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()
maruel@chromium.org004fb712011-06-21 20:02:16 +000045 except IOError:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000046 return None
47 return settings.get(key, None)
48
49
maruel@chromium.org4dd9f722012-10-01 16:23:03 +000050def align_stdout(stdout):
51 """Returns the aligned output of multiple stdouts."""
52 output = ''
53 for item in stdout:
54 item = item.strip()
55 if not item:
56 continue
57 output += ''.join(' %s\n' % line for line in item.splitlines())
58 return output
59
60
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000061class PatchApplicationFailed(Exception):
62 """Patch failed to be applied."""
maruel@chromium.org34f68552012-05-09 19:18:36 +000063 def __init__(self, p, status):
64 super(PatchApplicationFailed, self).__init__(p, status)
65 self.patch = p
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000066 self.status = status
67
maruel@chromium.org34f68552012-05-09 19:18:36 +000068 @property
69 def filename(self):
70 if self.patch:
71 return self.patch.filename
72
73 def __str__(self):
74 out = []
75 if self.filename:
76 out.append('Failed to apply patch for %s:' % self.filename)
77 if self.status:
78 out.append(self.status)
maruel@chromium.org4dd9f722012-10-01 16:23:03 +000079 out.append('Patch: %s' % self.patch.dump())
maruel@chromium.org34f68552012-05-09 19:18:36 +000080 return '\n'.join(out)
81
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000082
83class CheckoutBase(object):
84 # Set to None to have verbose output.
85 VOID = subprocess2.VOID
86
maruel@chromium.org6ed8b502011-06-12 01:05:35 +000087 def __init__(self, root_dir, project_name, post_processors):
88 """
89 Args:
90 post_processor: list of lambda(checkout, patches) to call on each of the
91 modified files.
92 """
maruel@chromium.orga5129fb2011-06-20 18:36:25 +000093 super(CheckoutBase, self).__init__()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000094 self.root_dir = root_dir
95 self.project_name = project_name
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +000096 if self.project_name is None:
97 self.project_path = self.root_dir
98 else:
99 self.project_path = os.path.join(self.root_dir, self.project_name)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000100 # Only used for logging purposes.
101 self._last_seen_revision = None
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000102 self.post_processors = post_processors
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000103 assert self.root_dir
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000104 assert self.project_path
maruel@chromium.org0aca0f92012-10-01 16:39:45 +0000105 assert os.path.isabs(self.project_path)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000106
107 def get_settings(self, key):
108 return get_code_review_setting(self.project_path, key)
109
maruel@chromium.org51919772011-06-12 01:27:42 +0000110 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000111 """Checks out a clean copy of the tree and removes any local modification.
112
113 This function shouldn't throw unless the remote repository is inaccessible,
114 there is no free disk space or hard issues like that.
maruel@chromium.org51919772011-06-12 01:27:42 +0000115
116 Args:
117 revision: The revision it should sync to, SCM specific.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000118 """
119 raise NotImplementedError()
120
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000121 def apply_patch(self, patches, post_processors=None, verbose=False):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000122 """Applies a patch and returns the list of modified files.
123
124 This function should throw patch.UnsupportedPatchFormat or
125 PatchApplicationFailed when relevant.
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000126
127 Args:
128 patches: patch.PatchSet object.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000129 """
130 raise NotImplementedError()
131
132 def commit(self, commit_message, user):
133 """Commits the patch upstream, while impersonating 'user'."""
134 raise NotImplementedError()
135
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000136 def revisions(self, rev1, rev2):
137 """Returns the count of revisions from rev1 to rev2, e.g. len(]rev1, rev2]).
138
139 If rev2 is None, it means 'HEAD'.
140
141 Returns None if there is no link between the two.
142 """
143 raise NotImplementedError()
144
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000145
146class RawCheckout(CheckoutBase):
147 """Used to apply a patch locally without any intent to commit it.
148
149 To be used by the try server.
150 """
maruel@chromium.org51919772011-06-12 01:27:42 +0000151 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000152 """Stubbed out."""
153 pass
154
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000155 def apply_patch(self, patches, post_processors=None, verbose=False):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000156 """Ignores svn properties."""
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000157 post_processors = post_processors or self.post_processors or []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000158 for p in patches:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000159 stdout = []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000160 try:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000161 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000162 if p.is_delete:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000163 os.remove(filepath)
164 stdout.append('Deleted.')
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000165 else:
166 dirname = os.path.dirname(p.filename)
167 full_dir = os.path.join(self.project_path, dirname)
168 if dirname and not os.path.isdir(full_dir):
169 os.makedirs(full_dir)
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000170 stdout.append('Created missing directory %s.' % dirname)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000171
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000172 if p.is_binary:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000173 content = p.get()
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000174 with open(filepath, 'wb') as f:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000175 f.write(content)
176 stdout.append('Added binary file %d bytes.' % len(content))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000177 else:
maruel@chromium.org5e975632011-09-29 18:07:06 +0000178 if p.source_filename:
179 if not p.is_new:
180 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000181 p,
maruel@chromium.org5e975632011-09-29 18:07:06 +0000182 'File has a source filename specified but is not new')
183 # Copy the file first.
184 if os.path.isfile(filepath):
185 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000186 p, 'File exist but was about to be overwriten')
maruel@chromium.org5e975632011-09-29 18:07:06 +0000187 shutil.copy2(
188 os.path.join(self.project_path, p.source_filename), filepath)
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000189 stdout.append('Copied %s -> %s' % (p.source_filename, p.filename))
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000190 if p.diff_hunks:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000191 cmd = ['patch', '-u', '--binary', '-p%s' % p.patchlevel]
192 if verbose:
193 cmd.append('--verbose')
194 stdout.append(
195 subprocess2.check_output(
196 cmd,
197 stdin=p.get(False),
198 stderr=subprocess2.STDOUT,
199 cwd=self.project_path))
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000200 elif p.is_new and not os.path.exists(filepath):
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000201 # There is only a header. Just create the file.
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000202 open(filepath, 'w').close()
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000203 stdout.append('Created an empty file.')
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000204 for post in post_processors:
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000205 post(self, p)
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000206 if verbose:
207 print p.filename
208 print align_stdout(stdout)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000209 except OSError, e:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000210 raise PatchApplicationFailed(p, '%s%s' % (align_stdout(stdout), e))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000211 except subprocess.CalledProcessError, e:
212 raise PatchApplicationFailed(
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000213 p,
214 'While running %s;\n%s%s' % (
215 ' '.join(e.cmd),
216 align_stdout(stdout),
217 align_stdout([getattr(e, 'stdout', '')])))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000218
219 def commit(self, commit_message, user):
220 """Stubbed out."""
221 raise NotImplementedError('RawCheckout can\'t commit')
222
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000223 def revisions(self, _rev1, _rev2):
224 return None
225
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000226
227class SvnConfig(object):
228 """Parses a svn configuration file."""
229 def __init__(self, svn_config_dir=None):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000230 super(SvnConfig, self).__init__()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000231 self.svn_config_dir = svn_config_dir
232 self.default = not bool(self.svn_config_dir)
233 if not self.svn_config_dir:
234 if sys.platform == 'win32':
235 self.svn_config_dir = os.path.join(os.environ['APPDATA'], 'Subversion')
236 else:
237 self.svn_config_dir = os.path.join(os.environ['HOME'], '.subversion')
238 svn_config_file = os.path.join(self.svn_config_dir, 'config')
239 parser = ConfigParser.SafeConfigParser()
240 if os.path.isfile(svn_config_file):
241 parser.read(svn_config_file)
242 else:
243 parser.add_section('auto-props')
244 self.auto_props = dict(parser.items('auto-props'))
245
246
247class SvnMixIn(object):
248 """MixIn class to add svn commands common to both svn and git-svn clients."""
249 # These members need to be set by the subclass.
250 commit_user = None
251 commit_pwd = None
252 svn_url = None
253 project_path = None
254 # Override at class level when necessary. If used, --non-interactive is
255 # implied.
256 svn_config = SvnConfig()
257 # Set to True when non-interactivity is necessary but a custom subversion
258 # configuration directory is not necessary.
259 non_interactive = False
260
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000261 def _add_svn_flags(self, args, non_interactive, credentials=True):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000262 args = ['svn'] + args
263 if not self.svn_config.default:
264 args.extend(['--config-dir', self.svn_config.svn_config_dir])
265 if not self.svn_config.default or self.non_interactive or non_interactive:
266 args.append('--non-interactive')
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000267 if credentials:
268 if self.commit_user:
269 args.extend(['--username', self.commit_user])
270 if self.commit_pwd:
271 args.extend(['--password', self.commit_pwd])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000272 return args
273
274 def _check_call_svn(self, args, **kwargs):
275 """Runs svn and throws an exception if the command failed."""
276 kwargs.setdefault('cwd', self.project_path)
277 kwargs.setdefault('stdout', self.VOID)
maruel@chromium.org0bcd1d32011-04-26 15:55:49 +0000278 return subprocess2.check_call_out(
279 self._add_svn_flags(args, False), **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000280
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000281 def _check_output_svn(self, args, credentials=True, **kwargs):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000282 """Runs svn and throws an exception if the command failed.
283
284 Returns the output.
285 """
286 kwargs.setdefault('cwd', self.project_path)
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000287 return subprocess2.check_output(
maruel@chromium.org87e6d332011-09-09 19:01:28 +0000288 self._add_svn_flags(args, True, credentials),
289 stderr=subprocess2.STDOUT,
290 **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000291
292 @staticmethod
293 def _parse_svn_info(output, key):
294 """Returns value for key from svn info output.
295
296 Case insensitive.
297 """
298 values = {}
299 key = key.lower()
300 for line in output.splitlines(False):
301 if not line:
302 continue
303 k, v = line.split(':', 1)
304 k = k.strip().lower()
305 v = v.strip()
306 assert not k in values
307 values[k] = v
308 return values.get(key, None)
309
310
311class SvnCheckout(CheckoutBase, SvnMixIn):
312 """Manages a subversion checkout."""
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000313 def __init__(self, root_dir, project_name, commit_user, commit_pwd, svn_url,
314 post_processors=None):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000315 CheckoutBase.__init__(self, root_dir, project_name, post_processors)
316 SvnMixIn.__init__(self)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000317 self.commit_user = commit_user
318 self.commit_pwd = commit_pwd
319 self.svn_url = svn_url
320 assert bool(self.commit_user) >= bool(self.commit_pwd)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000321
maruel@chromium.org51919772011-06-12 01:27:42 +0000322 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000323 # Will checkout if the directory is not present.
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000324 assert self.svn_url
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000325 if not os.path.isdir(self.project_path):
326 logging.info('Checking out %s in %s' %
327 (self.project_name, self.project_path))
maruel@chromium.org51919772011-06-12 01:27:42 +0000328 return self._revert(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000329
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000330 def apply_patch(self, patches, post_processors=None, verbose=False):
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000331 post_processors = post_processors or self.post_processors or []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000332 for p in patches:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000333 stdout = []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000334 try:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000335 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000336 # It is important to use credentials=False otherwise credentials could
337 # leak in the error message. Credentials are not necessary here for the
338 # following commands anyway.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000339 if p.is_delete:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000340 stdout.append(self._check_output_svn(
341 ['delete', p.filename, '--force'], credentials=False))
342 stdout.append('Deleted.')
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000343 else:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000344 # svn add while creating directories otherwise svn add on the
345 # contained files will silently fail.
346 # First, find the root directory that exists.
347 dirname = os.path.dirname(p.filename)
348 dirs_to_create = []
349 while (dirname and
350 not os.path.isdir(os.path.join(self.project_path, dirname))):
351 dirs_to_create.append(dirname)
352 dirname = os.path.dirname(dirname)
353 for dir_to_create in reversed(dirs_to_create):
354 os.mkdir(os.path.join(self.project_path, dir_to_create))
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000355 stdout.append(
356 self._check_output_svn(
357 ['add', dir_to_create, '--force'], credentials=False))
358 stdout.append('Created missing directory %s.' % dir_to_create)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000359
360 if p.is_binary:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000361 content = p.get()
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000362 with open(filepath, 'wb') as f:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000363 f.write(content)
364 stdout.append('Added binary file %d bytes.' % len(content))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000365 else:
maruel@chromium.org5e975632011-09-29 18:07:06 +0000366 if p.source_filename:
367 if not p.is_new:
368 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000369 p,
maruel@chromium.org5e975632011-09-29 18:07:06 +0000370 'File has a source filename specified but is not new')
371 # Copy the file first.
372 if os.path.isfile(filepath):
373 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000374 p, 'File exist but was about to be overwriten')
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000375 stdout.append(
376 self._check_output_svn(
377 ['copy', p.source_filename, p.filename]))
378 stdout.append('Copied %s -> %s' % (p.source_filename, p.filename))
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000379 if p.diff_hunks:
maruel@chromium.orgec4a9182012-09-28 20:39:45 +0000380 cmd = [
381 'patch',
382 '-p%s' % p.patchlevel,
383 '--forward',
384 '--force',
385 '--no-backup-if-mismatch',
386 ]
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000387 stdout.append(
388 subprocess2.check_output(
389 cmd, stdin=p.get(False), cwd=self.project_path))
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000390 elif p.is_new and not os.path.exists(filepath):
391 # There is only a header. Just create the file if it doesn't
392 # exist.
393 open(filepath, 'w').close()
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000394 stdout.append('Created an empty file.')
maruel@chromium.org3da83172012-05-07 16:17:20 +0000395 if p.is_new and not p.source_filename:
396 # Do not run it if p.source_filename is defined, since svn copy was
397 # using above.
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000398 stdout.append(
399 self._check_output_svn(
400 ['add', p.filename, '--force'], credentials=False))
maruel@chromium.orgd7ca6162012-08-29 17:22:22 +0000401 for name, value in p.svn_properties:
402 if value is None:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000403 stdout.append(
404 self._check_output_svn(
405 ['propdel', '--quiet', name, p.filename],
406 credentials=False))
407 stdout.append('Property %s deleted.' % name)
maruel@chromium.orgd7ca6162012-08-29 17:22:22 +0000408 else:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000409 stdout.append(
410 self._check_output_svn(
411 ['propset', name, value, p.filename], credentials=False))
412 stdout.append('Property %s=%s' % (name, value))
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000413 for prop, values in self.svn_config.auto_props.iteritems():
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000414 if fnmatch.fnmatch(p.filename, prop):
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000415 for value in values.split(';'):
416 if '=' not in value:
maruel@chromium.orge1a03762012-09-24 15:28:52 +0000417 params = [value, '.']
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000418 else:
419 params = value.split('=', 1)
maruel@chromium.orge1a03762012-09-24 15:28:52 +0000420 if params[1] == '*':
421 # Works around crbug.com/150960 on Windows.
422 params[1] = '.'
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000423 stdout.append(
424 self._check_output_svn(
425 ['propset'] + params + [p.filename], credentials=False))
426 stdout.append('Property (auto) %s' % '='.join(params))
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000427 for post in post_processors:
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000428 post(self, p)
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000429 if verbose:
430 print p.filename
431 print align_stdout(stdout)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000432 except OSError, e:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000433 raise PatchApplicationFailed(p, '%s%s' % (align_stdout(stdout), e))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000434 except subprocess.CalledProcessError, e:
435 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000436 p,
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000437 'While running %s;\n%s%s' % (
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000438 ' '.join(e.cmd),
439 align_stdout(stdout),
440 align_stdout([getattr(e, 'stdout', '')])))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000441
442 def commit(self, commit_message, user):
443 logging.info('Committing patch for %s' % user)
444 assert self.commit_user
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000445 assert isinstance(commit_message, unicode)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000446 handle, commit_filename = tempfile.mkstemp(text=True)
447 try:
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000448 # Shouldn't assume default encoding is UTF-8. But really, if you are using
449 # anything else, you are living in another world.
450 os.write(handle, commit_message.encode('utf-8'))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000451 os.close(handle)
452 # When committing, svn won't update the Revision metadata of the checkout,
453 # so if svn commit returns "Committed revision 3.", svn info will still
454 # return "Revision: 2". Since running svn update right after svn commit
455 # creates a race condition with other committers, this code _must_ parse
456 # the output of svn commit and use a regexp to grab the revision number.
457 # Note that "Committed revision N." is localized but subprocess2 forces
458 # LANGUAGE=en.
459 args = ['commit', '--file', commit_filename]
460 # realauthor is parsed by a server-side hook.
461 if user and user != self.commit_user:
462 args.extend(['--with-revprop', 'realauthor=%s' % user])
463 out = self._check_output_svn(args)
464 finally:
465 os.remove(commit_filename)
466 lines = filter(None, out.splitlines())
467 match = re.match(r'^Committed revision (\d+).$', lines[-1])
468 if not match:
469 raise PatchApplicationFailed(
470 None,
471 'Couldn\'t make sense out of svn commit message:\n' + out)
472 return int(match.group(1))
473
maruel@chromium.org51919772011-06-12 01:27:42 +0000474 def _revert(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000475 """Reverts local modifications or checks out if the directory is not
476 present. Use depot_tools's functionality to do this.
477 """
478 flags = ['--ignore-externals']
maruel@chromium.org51919772011-06-12 01:27:42 +0000479 if revision:
480 flags.extend(['--revision', str(revision)])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000481 if not os.path.isdir(self.project_path):
482 logging.info(
483 'Directory %s is not present, checking it out.' % self.project_path)
484 self._check_call_svn(
485 ['checkout', self.svn_url, self.project_path] + flags, cwd=None)
486 else:
maruel@chromium.orgea15cb72012-05-04 14:16:31 +0000487 scm.SVN.Revert(self.project_path, no_ignore=True)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000488 # Revive files that were deleted in scm.SVN.Revert().
489 self._check_call_svn(['update', '--force'] + flags)
maruel@chromium.org51919772011-06-12 01:27:42 +0000490 return self._get_revision()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000491
maruel@chromium.org51919772011-06-12 01:27:42 +0000492 def _get_revision(self):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000493 out = self._check_output_svn(['info', '.'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000494 revision = int(self._parse_svn_info(out, 'revision'))
495 if revision != self._last_seen_revision:
496 logging.info('Updated to revision %d' % revision)
497 self._last_seen_revision = revision
498 return revision
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000499
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000500 def revisions(self, rev1, rev2):
501 """Returns the number of actual commits, not just the difference between
502 numbers.
503 """
504 rev2 = rev2 or 'HEAD'
505 # Revision range is inclusive and ordering doesn't matter, they'll appear in
506 # the order specified.
507 try:
508 out = self._check_output_svn(
509 ['log', '-q', self.svn_url, '-r', '%s:%s' % (rev1, rev2)])
510 except subprocess.CalledProcessError:
511 return None
512 # Ignore the '----' lines.
513 return len([l for l in out.splitlines() if l.startswith('r')]) - 1
514
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000515
516class GitCheckoutBase(CheckoutBase):
517 """Base class for git checkout. Not to be used as-is."""
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000518 def __init__(self, root_dir, project_name, remote_branch,
519 post_processors=None):
520 super(GitCheckoutBase, self).__init__(
521 root_dir, project_name, post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000522 # There is no reason to not hardcode it.
523 self.remote = 'origin'
524 self.remote_branch = remote_branch
525 self.working_branch = 'working_branch'
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000526
maruel@chromium.org51919772011-06-12 01:27:42 +0000527 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000528 """Resets the git repository in a clean state.
529
530 Checks it out if not present and deletes the working branch.
531 """
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000532 assert self.remote_branch
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000533 assert os.path.isdir(self.project_path)
534 self._check_call_git(['reset', '--hard', '--quiet'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000535 if revision:
536 try:
537 revision = self._check_output_git(['rev-parse', revision])
538 except subprocess.CalledProcessError:
539 self._check_call_git(
540 ['fetch', self.remote, self.remote_branch, '--quiet'])
541 revision = self._check_output_git(['rev-parse', revision])
542 self._check_call_git(['checkout', '--force', '--quiet', revision])
543 else:
544 branches, active = self._branches()
545 if active != 'master':
546 self._check_call_git(['checkout', '--force', '--quiet', 'master'])
547 self._check_call_git(['pull', self.remote, self.remote_branch, '--quiet'])
548 if self.working_branch in branches:
549 self._call_git(['branch', '-D', self.working_branch])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000550
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000551 def apply_patch(self, patches, post_processors=None, verbose=False):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000552 """Applies a patch on 'working_branch' and switch to it.
553
554 Also commits the changes on the local branch.
555
556 Ignores svn properties and raise an exception on unexpected ones.
557 """
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000558 post_processors = post_processors or self.post_processors or []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000559 # It this throws, the checkout is corrupted. Maybe worth deleting it and
560 # trying again?
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000561 if self.remote_branch:
562 self._check_call_git(
563 ['checkout', '-b', self.working_branch,
564 '%s/%s' % (self.remote, self.remote_branch), '--quiet'])
maruel@chromium.org5e975632011-09-29 18:07:06 +0000565 for index, p in enumerate(patches):
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000566 stdout = []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000567 try:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000568 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000569 if p.is_delete:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000570 if (not os.path.exists(filepath) and
maruel@chromium.org5e975632011-09-29 18:07:06 +0000571 any(p1.source_filename == p.filename for p1 in patches[0:index])):
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000572 # The file was already deleted if a prior patch with file rename
573 # was already processed because 'git apply' did it for us.
maruel@chromium.org5e975632011-09-29 18:07:06 +0000574 pass
575 else:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000576 stdout.append(self._check_output_git(['rm', p.filename]))
577 stdout.append('Deleted.')
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000578 else:
579 dirname = os.path.dirname(p.filename)
580 full_dir = os.path.join(self.project_path, dirname)
581 if dirname and not os.path.isdir(full_dir):
582 os.makedirs(full_dir)
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000583 stdout.append('Created missing directory %s.' % dirname)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000584 if p.is_binary:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000585 content = p.get()
586 with open(filepath, 'wb') as f:
587 f.write(content)
588 stdout.append('Added binary file %d bytes' % len(content))
589 cmd = ['add', p.filename]
590 if verbose:
591 cmd.append('--verbose')
592 stdout.append(self._check_output_git(cmd))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000593 else:
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000594 # No need to do anything special with p.is_new or if not
595 # p.diff_hunks. git apply manages all that already.
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000596 cmd = ['apply', '--index', '-p%s' % p.patchlevel]
597 if verbose:
598 cmd.append('--verbose')
599 stdout.append(self._check_output_git(cmd, stdin=p.get(True)))
600 for name, value in p.svn_properties:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000601 # Ignore some known auto-props flags through .subversion/config,
602 # bails out on the other ones.
603 # TODO(maruel): Read ~/.subversion/config and detect the rules that
604 # applies here to figure out if the property will be correctly
605 # handled.
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000606 stdout.append('Property %s=%s' % (name, value))
maruel@chromium.orgd7ca6162012-08-29 17:22:22 +0000607 if not name in (
maruel@chromium.org9799a072012-01-11 00:26:25 +0000608 'svn:eol-style', 'svn:executable', 'svn:mime-type'):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000609 raise patch.UnsupportedPatchFormat(
610 p.filename,
611 'Cannot apply svn property %s to file %s.' % (
maruel@chromium.orgd7ca6162012-08-29 17:22:22 +0000612 name, p.filename))
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000613 for post in post_processors:
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000614 post(self, p)
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000615 if verbose:
616 print p.filename
617 print align_stdout(stdout)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000618 except OSError, e:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000619 raise PatchApplicationFailed(p, '%s%s' % (align_stdout(stdout), e))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000620 except subprocess.CalledProcessError, e:
621 raise PatchApplicationFailed(
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000622 p,
623 'While running %s;\n%s%s' % (
624 ' '.join(e.cmd),
625 align_stdout(stdout),
626 align_stdout([getattr(e, 'stdout', '')])))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000627 # Once all the patches are processed and added to the index, commit the
628 # index.
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000629 cmd = ['commit', '-m', 'Committed patch']
630 if verbose:
631 cmd.append('--verbose')
632 self._check_call_git(cmd)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000633 # TODO(maruel): Weirdly enough they don't match, need to investigate.
634 #found_files = self._check_output_git(
635 # ['diff', 'master', '--name-only']).splitlines(False)
636 #assert sorted(patches.filenames) == sorted(found_files), (
637 # sorted(out), sorted(found_files))
638
639 def commit(self, commit_message, user):
640 """Updates the commit message.
641
642 Subclass needs to dcommit or push.
643 """
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000644 assert isinstance(commit_message, unicode)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000645 self._check_call_git(['commit', '--amend', '-m', commit_message])
646 return self._check_output_git(['rev-parse', 'HEAD']).strip()
647
648 def _check_call_git(self, args, **kwargs):
649 kwargs.setdefault('cwd', self.project_path)
650 kwargs.setdefault('stdout', self.VOID)
maruel@chromium.org0bcd1d32011-04-26 15:55:49 +0000651 return subprocess2.check_call_out(['git'] + args, **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000652
653 def _call_git(self, args, **kwargs):
654 """Like check_call but doesn't throw on failure."""
655 kwargs.setdefault('cwd', self.project_path)
656 kwargs.setdefault('stdout', self.VOID)
657 return subprocess2.call(['git'] + args, **kwargs)
658
659 def _check_output_git(self, args, **kwargs):
660 kwargs.setdefault('cwd', self.project_path)
maruel@chromium.org87e6d332011-09-09 19:01:28 +0000661 return subprocess2.check_output(
662 ['git'] + args, stderr=subprocess2.STDOUT, **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000663
664 def _branches(self):
665 """Returns the list of branches and the active one."""
666 out = self._check_output_git(['branch']).splitlines(False)
667 branches = [l[2:] for l in out]
668 active = None
669 for l in out:
670 if l.startswith('*'):
671 active = l[2:]
672 break
673 return branches, active
674
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000675 def revisions(self, rev1, rev2):
676 """Returns the number of actual commits between both hash."""
677 self._fetch_remote()
678
679 rev2 = rev2 or '%s/%s' % (self.remote, self.remote_branch)
680 # Revision range is ]rev1, rev2] and ordering matters.
681 try:
682 out = self._check_output_git(
683 ['log', '--format="%H"' , '%s..%s' % (rev1, rev2)])
684 except subprocess.CalledProcessError:
685 return None
686 return len(out.splitlines())
687
688 def _fetch_remote(self):
689 """Fetches the remote without rebasing."""
690 raise NotImplementedError()
691
692
693class GitCheckout(GitCheckoutBase):
694 """Git checkout implementation."""
695 def _fetch_remote(self):
696 # git fetch is always verbose even with -q -q so redirect its output.
697 self._check_output_git(['fetch', self.remote, self.remote_branch])
698
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000699
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000700class ReadOnlyCheckout(object):
701 """Converts a checkout into a read-only one."""
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000702 def __init__(self, checkout, post_processors=None):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000703 super(ReadOnlyCheckout, self).__init__()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000704 self.checkout = checkout
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000705 self.post_processors = (post_processors or []) + (
706 self.checkout.post_processors or [])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000707
maruel@chromium.org51919772011-06-12 01:27:42 +0000708 def prepare(self, revision):
709 return self.checkout.prepare(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000710
711 def get_settings(self, key):
712 return self.checkout.get_settings(key)
713
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000714 def apply_patch(self, patches, post_processors=None, verbose=False):
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000715 return self.checkout.apply_patch(
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000716 patches, post_processors or self.post_processors, verbose)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000717
718 def commit(self, message, user): # pylint: disable=R0201
719 logging.info('Would have committed for %s with message: %s' % (
720 user, message))
721 return 'FAKE'
722
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000723 def revisions(self, rev1, rev2):
724 return self.checkout.revisions(rev1, rev2)
725
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000726 @property
727 def project_name(self):
728 return self.checkout.project_name
729
730 @property
731 def project_path(self):
732 return self.checkout.project_path