blob: 5fd236b4a7245ee024775d626dd300decf47c933 [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
105
106 def get_settings(self, key):
107 return get_code_review_setting(self.project_path, key)
108
maruel@chromium.org51919772011-06-12 01:27:42 +0000109 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000110 """Checks out a clean copy of the tree and removes any local modification.
111
112 This function shouldn't throw unless the remote repository is inaccessible,
113 there is no free disk space or hard issues like that.
maruel@chromium.org51919772011-06-12 01:27:42 +0000114
115 Args:
116 revision: The revision it should sync to, SCM specific.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000117 """
118 raise NotImplementedError()
119
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000120 def apply_patch(self, patches, post_processors=None, verbose=False):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000121 """Applies a patch and returns the list of modified files.
122
123 This function should throw patch.UnsupportedPatchFormat or
124 PatchApplicationFailed when relevant.
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000125
126 Args:
127 patches: patch.PatchSet object.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000128 """
129 raise NotImplementedError()
130
131 def commit(self, commit_message, user):
132 """Commits the patch upstream, while impersonating 'user'."""
133 raise NotImplementedError()
134
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000135 def revisions(self, rev1, rev2):
136 """Returns the count of revisions from rev1 to rev2, e.g. len(]rev1, rev2]).
137
138 If rev2 is None, it means 'HEAD'.
139
140 Returns None if there is no link between the two.
141 """
142 raise NotImplementedError()
143
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000144
145class RawCheckout(CheckoutBase):
146 """Used to apply a patch locally without any intent to commit it.
147
148 To be used by the try server.
149 """
maruel@chromium.org51919772011-06-12 01:27:42 +0000150 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000151 """Stubbed out."""
152 pass
153
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000154 def apply_patch(self, patches, post_processors=None, verbose=False):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000155 """Ignores svn properties."""
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000156 post_processors = post_processors or self.post_processors or []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000157 for p in patches:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000158 stdout = []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000159 try:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000160 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000161 if p.is_delete:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000162 os.remove(filepath)
163 stdout.append('Deleted.')
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000164 else:
165 dirname = os.path.dirname(p.filename)
166 full_dir = os.path.join(self.project_path, dirname)
167 if dirname and not os.path.isdir(full_dir):
168 os.makedirs(full_dir)
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000169 stdout.append('Created missing directory %s.' % dirname)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000170
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000171 if p.is_binary:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000172 content = p.get()
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000173 with open(filepath, 'wb') as f:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000174 f.write(content)
175 stdout.append('Added binary file %d bytes.' % len(content))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000176 else:
maruel@chromium.org5e975632011-09-29 18:07:06 +0000177 if p.source_filename:
178 if not p.is_new:
179 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000180 p,
maruel@chromium.org5e975632011-09-29 18:07:06 +0000181 'File has a source filename specified but is not new')
182 # Copy the file first.
183 if os.path.isfile(filepath):
184 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000185 p, 'File exist but was about to be overwriten')
maruel@chromium.org5e975632011-09-29 18:07:06 +0000186 shutil.copy2(
187 os.path.join(self.project_path, p.source_filename), filepath)
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000188 stdout.append('Copied %s -> %s' % (p.source_filename, p.filename))
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000189 if p.diff_hunks:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000190 cmd = ['patch', '-u', '--binary', '-p%s' % p.patchlevel]
191 if verbose:
192 cmd.append('--verbose')
193 stdout.append(
194 subprocess2.check_output(
195 cmd,
196 stdin=p.get(False),
197 stderr=subprocess2.STDOUT,
198 cwd=self.project_path))
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000199 elif p.is_new and not os.path.exists(filepath):
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000200 # There is only a header. Just create the file.
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000201 open(filepath, 'w').close()
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000202 stdout.append('Created an empty file.')
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000203 for post in post_processors:
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000204 post(self, p)
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000205 if verbose:
206 print p.filename
207 print align_stdout(stdout)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000208 except OSError, e:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000209 raise PatchApplicationFailed(p, '%s%s' % (align_stdout(stdout), e))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000210 except subprocess.CalledProcessError, e:
211 raise PatchApplicationFailed(
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000212 p,
213 'While running %s;\n%s%s' % (
214 ' '.join(e.cmd),
215 align_stdout(stdout),
216 align_stdout([getattr(e, 'stdout', '')])))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000217
218 def commit(self, commit_message, user):
219 """Stubbed out."""
220 raise NotImplementedError('RawCheckout can\'t commit')
221
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000222 def revisions(self, _rev1, _rev2):
223 return None
224
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000225
226class SvnConfig(object):
227 """Parses a svn configuration file."""
228 def __init__(self, svn_config_dir=None):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000229 super(SvnConfig, self).__init__()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000230 self.svn_config_dir = svn_config_dir
231 self.default = not bool(self.svn_config_dir)
232 if not self.svn_config_dir:
233 if sys.platform == 'win32':
234 self.svn_config_dir = os.path.join(os.environ['APPDATA'], 'Subversion')
235 else:
236 self.svn_config_dir = os.path.join(os.environ['HOME'], '.subversion')
237 svn_config_file = os.path.join(self.svn_config_dir, 'config')
238 parser = ConfigParser.SafeConfigParser()
239 if os.path.isfile(svn_config_file):
240 parser.read(svn_config_file)
241 else:
242 parser.add_section('auto-props')
243 self.auto_props = dict(parser.items('auto-props'))
244
245
246class SvnMixIn(object):
247 """MixIn class to add svn commands common to both svn and git-svn clients."""
248 # These members need to be set by the subclass.
249 commit_user = None
250 commit_pwd = None
251 svn_url = None
252 project_path = None
253 # Override at class level when necessary. If used, --non-interactive is
254 # implied.
255 svn_config = SvnConfig()
256 # Set to True when non-interactivity is necessary but a custom subversion
257 # configuration directory is not necessary.
258 non_interactive = False
259
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000260 def _add_svn_flags(self, args, non_interactive, credentials=True):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000261 args = ['svn'] + args
262 if not self.svn_config.default:
263 args.extend(['--config-dir', self.svn_config.svn_config_dir])
264 if not self.svn_config.default or self.non_interactive or non_interactive:
265 args.append('--non-interactive')
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000266 if credentials:
267 if self.commit_user:
268 args.extend(['--username', self.commit_user])
269 if self.commit_pwd:
270 args.extend(['--password', self.commit_pwd])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000271 return args
272
273 def _check_call_svn(self, args, **kwargs):
274 """Runs svn and throws an exception if the command failed."""
275 kwargs.setdefault('cwd', self.project_path)
276 kwargs.setdefault('stdout', self.VOID)
maruel@chromium.org0bcd1d32011-04-26 15:55:49 +0000277 return subprocess2.check_call_out(
278 self._add_svn_flags(args, False), **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000279
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000280 def _check_output_svn(self, args, credentials=True, **kwargs):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000281 """Runs svn and throws an exception if the command failed.
282
283 Returns the output.
284 """
285 kwargs.setdefault('cwd', self.project_path)
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000286 return subprocess2.check_output(
maruel@chromium.org87e6d332011-09-09 19:01:28 +0000287 self._add_svn_flags(args, True, credentials),
288 stderr=subprocess2.STDOUT,
289 **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000290
291 @staticmethod
292 def _parse_svn_info(output, key):
293 """Returns value for key from svn info output.
294
295 Case insensitive.
296 """
297 values = {}
298 key = key.lower()
299 for line in output.splitlines(False):
300 if not line:
301 continue
302 k, v = line.split(':', 1)
303 k = k.strip().lower()
304 v = v.strip()
305 assert not k in values
306 values[k] = v
307 return values.get(key, None)
308
309
310class SvnCheckout(CheckoutBase, SvnMixIn):
311 """Manages a subversion checkout."""
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000312 def __init__(self, root_dir, project_name, commit_user, commit_pwd, svn_url,
313 post_processors=None):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000314 CheckoutBase.__init__(self, root_dir, project_name, post_processors)
315 SvnMixIn.__init__(self)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000316 self.commit_user = commit_user
317 self.commit_pwd = commit_pwd
318 self.svn_url = svn_url
319 assert bool(self.commit_user) >= bool(self.commit_pwd)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000320
maruel@chromium.org51919772011-06-12 01:27:42 +0000321 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000322 # Will checkout if the directory is not present.
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000323 assert self.svn_url
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000324 if not os.path.isdir(self.project_path):
325 logging.info('Checking out %s in %s' %
326 (self.project_name, self.project_path))
maruel@chromium.org51919772011-06-12 01:27:42 +0000327 return self._revert(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000328
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000329 def apply_patch(self, patches, post_processors=None, verbose=False):
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000330 post_processors = post_processors or self.post_processors or []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000331 for p in patches:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000332 stdout = []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000333 try:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000334 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000335 # It is important to use credentials=False otherwise credentials could
336 # leak in the error message. Credentials are not necessary here for the
337 # following commands anyway.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000338 if p.is_delete:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000339 stdout.append(self._check_output_svn(
340 ['delete', p.filename, '--force'], credentials=False))
341 stdout.append('Deleted.')
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000342 else:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000343 # svn add while creating directories otherwise svn add on the
344 # contained files will silently fail.
345 # First, find the root directory that exists.
346 dirname = os.path.dirname(p.filename)
347 dirs_to_create = []
348 while (dirname and
349 not os.path.isdir(os.path.join(self.project_path, dirname))):
350 dirs_to_create.append(dirname)
351 dirname = os.path.dirname(dirname)
352 for dir_to_create in reversed(dirs_to_create):
353 os.mkdir(os.path.join(self.project_path, dir_to_create))
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000354 stdout.append(
355 self._check_output_svn(
356 ['add', dir_to_create, '--force'], credentials=False))
357 stdout.append('Created missing directory %s.' % dir_to_create)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000358
359 if p.is_binary:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000360 content = p.get()
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000361 with open(filepath, 'wb') as f:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000362 f.write(content)
363 stdout.append('Added binary file %d bytes.' % len(content))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000364 else:
maruel@chromium.org5e975632011-09-29 18:07:06 +0000365 if p.source_filename:
366 if not p.is_new:
367 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000368 p,
maruel@chromium.org5e975632011-09-29 18:07:06 +0000369 'File has a source filename specified but is not new')
370 # Copy the file first.
371 if os.path.isfile(filepath):
372 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000373 p, 'File exist but was about to be overwriten')
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000374 stdout.append(
375 self._check_output_svn(
376 ['copy', p.source_filename, p.filename]))
377 stdout.append('Copied %s -> %s' % (p.source_filename, p.filename))
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000378 if p.diff_hunks:
maruel@chromium.orgec4a9182012-09-28 20:39:45 +0000379 cmd = [
380 'patch',
381 '-p%s' % p.patchlevel,
382 '--forward',
383 '--force',
384 '--no-backup-if-mismatch',
385 ]
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000386 stdout.append(
387 subprocess2.check_output(
388 cmd, stdin=p.get(False), cwd=self.project_path))
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000389 elif p.is_new and not os.path.exists(filepath):
390 # There is only a header. Just create the file if it doesn't
391 # exist.
392 open(filepath, 'w').close()
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000393 stdout.append('Created an empty file.')
maruel@chromium.org3da83172012-05-07 16:17:20 +0000394 if p.is_new and not p.source_filename:
395 # Do not run it if p.source_filename is defined, since svn copy was
396 # using above.
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000397 stdout.append(
398 self._check_output_svn(
399 ['add', p.filename, '--force'], credentials=False))
maruel@chromium.orgd7ca6162012-08-29 17:22:22 +0000400 for name, value in p.svn_properties:
401 if value is None:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000402 stdout.append(
403 self._check_output_svn(
404 ['propdel', '--quiet', name, p.filename],
405 credentials=False))
406 stdout.append('Property %s deleted.' % name)
maruel@chromium.orgd7ca6162012-08-29 17:22:22 +0000407 else:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000408 stdout.append(
409 self._check_output_svn(
410 ['propset', name, value, p.filename], credentials=False))
411 stdout.append('Property %s=%s' % (name, value))
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000412 for prop, values in self.svn_config.auto_props.iteritems():
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000413 if fnmatch.fnmatch(p.filename, prop):
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000414 for value in values.split(';'):
415 if '=' not in value:
maruel@chromium.orge1a03762012-09-24 15:28:52 +0000416 params = [value, '.']
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000417 else:
418 params = value.split('=', 1)
maruel@chromium.orge1a03762012-09-24 15:28:52 +0000419 if params[1] == '*':
420 # Works around crbug.com/150960 on Windows.
421 params[1] = '.'
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000422 stdout.append(
423 self._check_output_svn(
424 ['propset'] + params + [p.filename], credentials=False))
425 stdout.append('Property (auto) %s' % '='.join(params))
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000426 for post in post_processors:
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000427 post(self, p)
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000428 if verbose:
429 print p.filename
430 print align_stdout(stdout)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000431 except OSError, e:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000432 raise PatchApplicationFailed(p, '%s%s' % (align_stdout(stdout), e))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000433 except subprocess.CalledProcessError, e:
434 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000435 p,
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000436 'While running %s;\n%s%s' % (
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000437 ' '.join(e.cmd),
438 align_stdout(stdout),
439 align_stdout([getattr(e, 'stdout', '')])))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000440
441 def commit(self, commit_message, user):
442 logging.info('Committing patch for %s' % user)
443 assert self.commit_user
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000444 assert isinstance(commit_message, unicode)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000445 handle, commit_filename = tempfile.mkstemp(text=True)
446 try:
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000447 # Shouldn't assume default encoding is UTF-8. But really, if you are using
448 # anything else, you are living in another world.
449 os.write(handle, commit_message.encode('utf-8'))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000450 os.close(handle)
451 # When committing, svn won't update the Revision metadata of the checkout,
452 # so if svn commit returns "Committed revision 3.", svn info will still
453 # return "Revision: 2". Since running svn update right after svn commit
454 # creates a race condition with other committers, this code _must_ parse
455 # the output of svn commit and use a regexp to grab the revision number.
456 # Note that "Committed revision N." is localized but subprocess2 forces
457 # LANGUAGE=en.
458 args = ['commit', '--file', commit_filename]
459 # realauthor is parsed by a server-side hook.
460 if user and user != self.commit_user:
461 args.extend(['--with-revprop', 'realauthor=%s' % user])
462 out = self._check_output_svn(args)
463 finally:
464 os.remove(commit_filename)
465 lines = filter(None, out.splitlines())
466 match = re.match(r'^Committed revision (\d+).$', lines[-1])
467 if not match:
468 raise PatchApplicationFailed(
469 None,
470 'Couldn\'t make sense out of svn commit message:\n' + out)
471 return int(match.group(1))
472
maruel@chromium.org51919772011-06-12 01:27:42 +0000473 def _revert(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000474 """Reverts local modifications or checks out if the directory is not
475 present. Use depot_tools's functionality to do this.
476 """
477 flags = ['--ignore-externals']
maruel@chromium.org51919772011-06-12 01:27:42 +0000478 if revision:
479 flags.extend(['--revision', str(revision)])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000480 if not os.path.isdir(self.project_path):
481 logging.info(
482 'Directory %s is not present, checking it out.' % self.project_path)
483 self._check_call_svn(
484 ['checkout', self.svn_url, self.project_path] + flags, cwd=None)
485 else:
maruel@chromium.orgea15cb72012-05-04 14:16:31 +0000486 scm.SVN.Revert(self.project_path, no_ignore=True)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000487 # Revive files that were deleted in scm.SVN.Revert().
488 self._check_call_svn(['update', '--force'] + flags)
maruel@chromium.org51919772011-06-12 01:27:42 +0000489 return self._get_revision()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000490
maruel@chromium.org51919772011-06-12 01:27:42 +0000491 def _get_revision(self):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000492 out = self._check_output_svn(['info', '.'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000493 revision = int(self._parse_svn_info(out, 'revision'))
494 if revision != self._last_seen_revision:
495 logging.info('Updated to revision %d' % revision)
496 self._last_seen_revision = revision
497 return revision
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000498
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000499 def revisions(self, rev1, rev2):
500 """Returns the number of actual commits, not just the difference between
501 numbers.
502 """
503 rev2 = rev2 or 'HEAD'
504 # Revision range is inclusive and ordering doesn't matter, they'll appear in
505 # the order specified.
506 try:
507 out = self._check_output_svn(
508 ['log', '-q', self.svn_url, '-r', '%s:%s' % (rev1, rev2)])
509 except subprocess.CalledProcessError:
510 return None
511 # Ignore the '----' lines.
512 return len([l for l in out.splitlines() if l.startswith('r')]) - 1
513
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000514
515class GitCheckoutBase(CheckoutBase):
516 """Base class for git checkout. Not to be used as-is."""
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000517 def __init__(self, root_dir, project_name, remote_branch,
518 post_processors=None):
519 super(GitCheckoutBase, self).__init__(
520 root_dir, project_name, post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000521 # There is no reason to not hardcode it.
522 self.remote = 'origin'
523 self.remote_branch = remote_branch
524 self.working_branch = 'working_branch'
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000525
maruel@chromium.org51919772011-06-12 01:27:42 +0000526 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000527 """Resets the git repository in a clean state.
528
529 Checks it out if not present and deletes the working branch.
530 """
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000531 assert self.remote_branch
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000532 assert os.path.isdir(self.project_path)
533 self._check_call_git(['reset', '--hard', '--quiet'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000534 if revision:
535 try:
536 revision = self._check_output_git(['rev-parse', revision])
537 except subprocess.CalledProcessError:
538 self._check_call_git(
539 ['fetch', self.remote, self.remote_branch, '--quiet'])
540 revision = self._check_output_git(['rev-parse', revision])
541 self._check_call_git(['checkout', '--force', '--quiet', revision])
542 else:
543 branches, active = self._branches()
544 if active != 'master':
545 self._check_call_git(['checkout', '--force', '--quiet', 'master'])
546 self._check_call_git(['pull', self.remote, self.remote_branch, '--quiet'])
547 if self.working_branch in branches:
548 self._call_git(['branch', '-D', self.working_branch])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000549
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000550 def apply_patch(self, patches, post_processors=None, verbose=False):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000551 """Applies a patch on 'working_branch' and switch to it.
552
553 Also commits the changes on the local branch.
554
555 Ignores svn properties and raise an exception on unexpected ones.
556 """
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000557 post_processors = post_processors or self.post_processors or []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000558 # It this throws, the checkout is corrupted. Maybe worth deleting it and
559 # trying again?
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000560 if self.remote_branch:
561 self._check_call_git(
562 ['checkout', '-b', self.working_branch,
563 '%s/%s' % (self.remote, self.remote_branch), '--quiet'])
maruel@chromium.org5e975632011-09-29 18:07:06 +0000564 for index, p in enumerate(patches):
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000565 stdout = []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000566 try:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000567 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000568 if p.is_delete:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000569 if (not os.path.exists(filepath) and
maruel@chromium.org5e975632011-09-29 18:07:06 +0000570 any(p1.source_filename == p.filename for p1 in patches[0:index])):
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000571 # The file was already deleted if a prior patch with file rename
572 # was already processed because 'git apply' did it for us.
maruel@chromium.org5e975632011-09-29 18:07:06 +0000573 pass
574 else:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000575 stdout.append(self._check_output_git(['rm', p.filename]))
576 stdout.append('Deleted.')
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000577 else:
578 dirname = os.path.dirname(p.filename)
579 full_dir = os.path.join(self.project_path, dirname)
580 if dirname and not os.path.isdir(full_dir):
581 os.makedirs(full_dir)
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000582 stdout.append('Created missing directory %s.' % dirname)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000583 if p.is_binary:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000584 content = p.get()
585 with open(filepath, 'wb') as f:
586 f.write(content)
587 stdout.append('Added binary file %d bytes' % len(content))
588 cmd = ['add', p.filename]
589 if verbose:
590 cmd.append('--verbose')
591 stdout.append(self._check_output_git(cmd))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000592 else:
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000593 # No need to do anything special with p.is_new or if not
594 # p.diff_hunks. git apply manages all that already.
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000595 cmd = ['apply', '--index', '-p%s' % p.patchlevel]
596 if verbose:
597 cmd.append('--verbose')
598 stdout.append(self._check_output_git(cmd, stdin=p.get(True)))
599 for name, value in p.svn_properties:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000600 # Ignore some known auto-props flags through .subversion/config,
601 # bails out on the other ones.
602 # TODO(maruel): Read ~/.subversion/config and detect the rules that
603 # applies here to figure out if the property will be correctly
604 # handled.
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000605 stdout.append('Property %s=%s' % (name, value))
maruel@chromium.orgd7ca6162012-08-29 17:22:22 +0000606 if not name in (
maruel@chromium.org9799a072012-01-11 00:26:25 +0000607 'svn:eol-style', 'svn:executable', 'svn:mime-type'):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000608 raise patch.UnsupportedPatchFormat(
609 p.filename,
610 'Cannot apply svn property %s to file %s.' % (
maruel@chromium.orgd7ca6162012-08-29 17:22:22 +0000611 name, p.filename))
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000612 for post in post_processors:
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000613 post(self, p)
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000614 if verbose:
615 print p.filename
616 print align_stdout(stdout)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000617 except OSError, e:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000618 raise PatchApplicationFailed(p, '%s%s' % (align_stdout(stdout), e))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000619 except subprocess.CalledProcessError, e:
620 raise PatchApplicationFailed(
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000621 p,
622 'While running %s;\n%s%s' % (
623 ' '.join(e.cmd),
624 align_stdout(stdout),
625 align_stdout([getattr(e, 'stdout', '')])))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000626 # Once all the patches are processed and added to the index, commit the
627 # index.
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000628 cmd = ['commit', '-m', 'Committed patch']
629 if verbose:
630 cmd.append('--verbose')
631 self._check_call_git(cmd)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000632 # TODO(maruel): Weirdly enough they don't match, need to investigate.
633 #found_files = self._check_output_git(
634 # ['diff', 'master', '--name-only']).splitlines(False)
635 #assert sorted(patches.filenames) == sorted(found_files), (
636 # sorted(out), sorted(found_files))
637
638 def commit(self, commit_message, user):
639 """Updates the commit message.
640
641 Subclass needs to dcommit or push.
642 """
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000643 assert isinstance(commit_message, unicode)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000644 self._check_call_git(['commit', '--amend', '-m', commit_message])
645 return self._check_output_git(['rev-parse', 'HEAD']).strip()
646
647 def _check_call_git(self, args, **kwargs):
648 kwargs.setdefault('cwd', self.project_path)
649 kwargs.setdefault('stdout', self.VOID)
maruel@chromium.org0bcd1d32011-04-26 15:55:49 +0000650 return subprocess2.check_call_out(['git'] + args, **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000651
652 def _call_git(self, args, **kwargs):
653 """Like check_call but doesn't throw on failure."""
654 kwargs.setdefault('cwd', self.project_path)
655 kwargs.setdefault('stdout', self.VOID)
656 return subprocess2.call(['git'] + args, **kwargs)
657
658 def _check_output_git(self, args, **kwargs):
659 kwargs.setdefault('cwd', self.project_path)
maruel@chromium.org87e6d332011-09-09 19:01:28 +0000660 return subprocess2.check_output(
661 ['git'] + args, stderr=subprocess2.STDOUT, **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000662
663 def _branches(self):
664 """Returns the list of branches and the active one."""
665 out = self._check_output_git(['branch']).splitlines(False)
666 branches = [l[2:] for l in out]
667 active = None
668 for l in out:
669 if l.startswith('*'):
670 active = l[2:]
671 break
672 return branches, active
673
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000674 def revisions(self, rev1, rev2):
675 """Returns the number of actual commits between both hash."""
676 self._fetch_remote()
677
678 rev2 = rev2 or '%s/%s' % (self.remote, self.remote_branch)
679 # Revision range is ]rev1, rev2] and ordering matters.
680 try:
681 out = self._check_output_git(
682 ['log', '--format="%H"' , '%s..%s' % (rev1, rev2)])
683 except subprocess.CalledProcessError:
684 return None
685 return len(out.splitlines())
686
687 def _fetch_remote(self):
688 """Fetches the remote without rebasing."""
689 raise NotImplementedError()
690
691
692class GitCheckout(GitCheckoutBase):
693 """Git checkout implementation."""
694 def _fetch_remote(self):
695 # git fetch is always verbose even with -q -q so redirect its output.
696 self._check_output_git(['fetch', self.remote, self.remote_branch])
697
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000698
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000699class ReadOnlyCheckout(object):
700 """Converts a checkout into a read-only one."""
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000701 def __init__(self, checkout, post_processors=None):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000702 super(ReadOnlyCheckout, self).__init__()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000703 self.checkout = checkout
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000704 self.post_processors = (post_processors or []) + (
705 self.checkout.post_processors or [])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000706
maruel@chromium.org51919772011-06-12 01:27:42 +0000707 def prepare(self, revision):
708 return self.checkout.prepare(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000709
710 def get_settings(self, key):
711 return self.checkout.get_settings(key)
712
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000713 def apply_patch(self, patches, post_processors=None, verbose=False):
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000714 return self.checkout.apply_patch(
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000715 patches, post_processors or self.post_processors, verbose)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000716
717 def commit(self, message, user): # pylint: disable=R0201
718 logging.info('Would have committed for %s with message: %s' % (
719 user, message))
720 return 'FAKE'
721
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000722 def revisions(self, rev1, rev2):
723 return self.checkout.revisions(rev1, rev2)
724
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000725 @property
726 def project_name(self):
727 return self.checkout.project_name
728
729 @property
730 def project_path(self):
731 return self.checkout.project_path