blob: e2e7e3ab9c97659b58814a35e3f363ae8fdd6281 [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.orgcb5667a2012-10-23 19:42:10 +000079 if self.patch:
80 out.append('Patch: %s' % self.patch.dump())
maruel@chromium.org34f68552012-05-09 19:18:36 +000081 return '\n'.join(out)
82
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000083
84class CheckoutBase(object):
85 # Set to None to have verbose output.
86 VOID = subprocess2.VOID
87
maruel@chromium.org6ed8b502011-06-12 01:05:35 +000088 def __init__(self, root_dir, project_name, post_processors):
89 """
90 Args:
91 post_processor: list of lambda(checkout, patches) to call on each of the
92 modified files.
93 """
maruel@chromium.orga5129fb2011-06-20 18:36:25 +000094 super(CheckoutBase, self).__init__()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000095 self.root_dir = root_dir
96 self.project_name = project_name
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +000097 if self.project_name is None:
98 self.project_path = self.root_dir
99 else:
100 self.project_path = os.path.join(self.root_dir, self.project_name)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000101 # Only used for logging purposes.
102 self._last_seen_revision = None
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000103 self.post_processors = post_processors
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000104 assert self.root_dir
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000105 assert self.project_path
maruel@chromium.org0aca0f92012-10-01 16:39:45 +0000106 assert os.path.isabs(self.project_path)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000107
108 def get_settings(self, key):
109 return get_code_review_setting(self.project_path, key)
110
maruel@chromium.org51919772011-06-12 01:27:42 +0000111 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000112 """Checks out a clean copy of the tree and removes any local modification.
113
114 This function shouldn't throw unless the remote repository is inaccessible,
115 there is no free disk space or hard issues like that.
maruel@chromium.org51919772011-06-12 01:27:42 +0000116
117 Args:
118 revision: The revision it should sync to, SCM specific.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000119 """
120 raise NotImplementedError()
121
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000122 def apply_patch(self, patches, post_processors=None, verbose=False):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000123 """Applies a patch and returns the list of modified files.
124
125 This function should throw patch.UnsupportedPatchFormat or
126 PatchApplicationFailed when relevant.
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000127
128 Args:
129 patches: patch.PatchSet object.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000130 """
131 raise NotImplementedError()
132
133 def commit(self, commit_message, user):
134 """Commits the patch upstream, while impersonating 'user'."""
135 raise NotImplementedError()
136
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000137 def revisions(self, rev1, rev2):
138 """Returns the count of revisions from rev1 to rev2, e.g. len(]rev1, rev2]).
139
140 If rev2 is None, it means 'HEAD'.
141
142 Returns None if there is no link between the two.
143 """
144 raise NotImplementedError()
145
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000146
147class RawCheckout(CheckoutBase):
148 """Used to apply a patch locally without any intent to commit it.
149
150 To be used by the try server.
151 """
maruel@chromium.org51919772011-06-12 01:27:42 +0000152 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000153 """Stubbed out."""
154 pass
155
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000156 def apply_patch(self, patches, post_processors=None, verbose=False):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000157 """Ignores svn properties."""
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000158 post_processors = post_processors or self.post_processors or []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000159 for p in patches:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000160 stdout = []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000161 try:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000162 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000163 if p.is_delete:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000164 os.remove(filepath)
165 stdout.append('Deleted.')
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000166 else:
167 dirname = os.path.dirname(p.filename)
168 full_dir = os.path.join(self.project_path, dirname)
169 if dirname and not os.path.isdir(full_dir):
170 os.makedirs(full_dir)
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000171 stdout.append('Created missing directory %s.' % dirname)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000172
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000173 if p.is_binary:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000174 content = p.get()
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000175 with open(filepath, 'wb') as f:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000176 f.write(content)
177 stdout.append('Added binary file %d bytes.' % len(content))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000178 else:
maruel@chromium.org5e975632011-09-29 18:07:06 +0000179 if p.source_filename:
180 if not p.is_new:
181 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000182 p,
maruel@chromium.org5e975632011-09-29 18:07:06 +0000183 'File has a source filename specified but is not new')
184 # Copy the file first.
185 if os.path.isfile(filepath):
186 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000187 p, 'File exist but was about to be overwriten')
maruel@chromium.org5e975632011-09-29 18:07:06 +0000188 shutil.copy2(
189 os.path.join(self.project_path, p.source_filename), filepath)
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000190 stdout.append('Copied %s -> %s' % (p.source_filename, p.filename))
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000191 if p.diff_hunks:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000192 cmd = ['patch', '-u', '--binary', '-p%s' % p.patchlevel]
193 if verbose:
194 cmd.append('--verbose')
195 stdout.append(
196 subprocess2.check_output(
197 cmd,
198 stdin=p.get(False),
199 stderr=subprocess2.STDOUT,
maruel@chromium.org44b21b92012-11-08 19:37:08 +0000200 cwd=self.project_path))
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000201 elif p.is_new and not os.path.exists(filepath):
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000202 # There is only a header. Just create the file.
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000203 open(filepath, 'w').close()
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000204 stdout.append('Created an empty file.')
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000205 for post in post_processors:
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000206 post(self, p)
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000207 if verbose:
208 print p.filename
209 print align_stdout(stdout)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000210 except OSError, e:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000211 raise PatchApplicationFailed(p, '%s%s' % (align_stdout(stdout), e))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000212 except subprocess.CalledProcessError, e:
213 raise PatchApplicationFailed(
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000214 p,
215 'While running %s;\n%s%s' % (
216 ' '.join(e.cmd),
217 align_stdout(stdout),
218 align_stdout([getattr(e, 'stdout', '')])))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000219
220 def commit(self, commit_message, user):
221 """Stubbed out."""
222 raise NotImplementedError('RawCheckout can\'t commit')
223
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000224 def revisions(self, _rev1, _rev2):
225 return None
226
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000227
228class SvnConfig(object):
229 """Parses a svn configuration file."""
230 def __init__(self, svn_config_dir=None):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000231 super(SvnConfig, self).__init__()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000232 self.svn_config_dir = svn_config_dir
233 self.default = not bool(self.svn_config_dir)
234 if not self.svn_config_dir:
235 if sys.platform == 'win32':
236 self.svn_config_dir = os.path.join(os.environ['APPDATA'], 'Subversion')
237 else:
238 self.svn_config_dir = os.path.join(os.environ['HOME'], '.subversion')
239 svn_config_file = os.path.join(self.svn_config_dir, 'config')
240 parser = ConfigParser.SafeConfigParser()
241 if os.path.isfile(svn_config_file):
242 parser.read(svn_config_file)
243 else:
244 parser.add_section('auto-props')
245 self.auto_props = dict(parser.items('auto-props'))
246
247
248class SvnMixIn(object):
249 """MixIn class to add svn commands common to both svn and git-svn clients."""
250 # These members need to be set by the subclass.
251 commit_user = None
252 commit_pwd = None
253 svn_url = None
254 project_path = None
255 # Override at class level when necessary. If used, --non-interactive is
256 # implied.
257 svn_config = SvnConfig()
258 # Set to True when non-interactivity is necessary but a custom subversion
259 # configuration directory is not necessary.
260 non_interactive = False
261
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000262 def _add_svn_flags(self, args, non_interactive, credentials=True):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000263 args = ['svn'] + args
264 if not self.svn_config.default:
265 args.extend(['--config-dir', self.svn_config.svn_config_dir])
266 if not self.svn_config.default or self.non_interactive or non_interactive:
267 args.append('--non-interactive')
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000268 if credentials:
269 if self.commit_user:
270 args.extend(['--username', self.commit_user])
271 if self.commit_pwd:
272 args.extend(['--password', self.commit_pwd])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000273 return args
274
275 def _check_call_svn(self, args, **kwargs):
276 """Runs svn and throws an exception if the command failed."""
277 kwargs.setdefault('cwd', self.project_path)
278 kwargs.setdefault('stdout', self.VOID)
maruel@chromium.org0bcd1d32011-04-26 15:55:49 +0000279 return subprocess2.check_call_out(
maruel@chromium.org44b21b92012-11-08 19:37:08 +0000280 self._add_svn_flags(args, False), **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000281
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000282 def _check_output_svn(self, args, credentials=True, **kwargs):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000283 """Runs svn and throws an exception if the command failed.
284
285 Returns the output.
286 """
287 kwargs.setdefault('cwd', self.project_path)
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000288 return subprocess2.check_output(
maruel@chromium.org87e6d332011-09-09 19:01:28 +0000289 self._add_svn_flags(args, True, credentials),
290 stderr=subprocess2.STDOUT,
291 **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000292
293 @staticmethod
294 def _parse_svn_info(output, key):
295 """Returns value for key from svn info output.
296
297 Case insensitive.
298 """
299 values = {}
300 key = key.lower()
301 for line in output.splitlines(False):
302 if not line:
303 continue
304 k, v = line.split(':', 1)
305 k = k.strip().lower()
306 v = v.strip()
307 assert not k in values
308 values[k] = v
309 return values.get(key, None)
310
311
312class SvnCheckout(CheckoutBase, SvnMixIn):
313 """Manages a subversion checkout."""
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000314 def __init__(self, root_dir, project_name, commit_user, commit_pwd, svn_url,
315 post_processors=None):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000316 CheckoutBase.__init__(self, root_dir, project_name, post_processors)
317 SvnMixIn.__init__(self)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000318 self.commit_user = commit_user
319 self.commit_pwd = commit_pwd
320 self.svn_url = svn_url
321 assert bool(self.commit_user) >= bool(self.commit_pwd)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000322
maruel@chromium.org51919772011-06-12 01:27:42 +0000323 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000324 # Will checkout if the directory is not present.
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000325 assert self.svn_url
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000326 if not os.path.isdir(self.project_path):
327 logging.info('Checking out %s in %s' %
328 (self.project_name, self.project_path))
maruel@chromium.org51919772011-06-12 01:27:42 +0000329 return self._revert(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000330
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000331 def apply_patch(self, patches, post_processors=None, verbose=False):
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000332 post_processors = post_processors or self.post_processors or []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000333 for p in patches:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000334 stdout = []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000335 try:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000336 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000337 # It is important to use credentials=False otherwise credentials could
338 # leak in the error message. Credentials are not necessary here for the
339 # following commands anyway.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000340 if p.is_delete:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000341 stdout.append(self._check_output_svn(
342 ['delete', p.filename, '--force'], credentials=False))
343 stdout.append('Deleted.')
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000344 else:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000345 # svn add while creating directories otherwise svn add on the
346 # contained files will silently fail.
347 # First, find the root directory that exists.
348 dirname = os.path.dirname(p.filename)
349 dirs_to_create = []
350 while (dirname and
351 not os.path.isdir(os.path.join(self.project_path, dirname))):
352 dirs_to_create.append(dirname)
353 dirname = os.path.dirname(dirname)
354 for dir_to_create in reversed(dirs_to_create):
355 os.mkdir(os.path.join(self.project_path, dir_to_create))
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000356 stdout.append(
357 self._check_output_svn(
358 ['add', dir_to_create, '--force'], credentials=False))
359 stdout.append('Created missing directory %s.' % dir_to_create)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000360
361 if p.is_binary:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000362 content = p.get()
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000363 with open(filepath, 'wb') as f:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000364 f.write(content)
365 stdout.append('Added binary file %d bytes.' % len(content))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000366 else:
maruel@chromium.org5e975632011-09-29 18:07:06 +0000367 if p.source_filename:
368 if not p.is_new:
369 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000370 p,
maruel@chromium.org5e975632011-09-29 18:07:06 +0000371 'File has a source filename specified but is not new')
372 # Copy the file first.
373 if os.path.isfile(filepath):
374 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000375 p, 'File exist but was about to be overwriten')
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000376 stdout.append(
377 self._check_output_svn(
378 ['copy', p.source_filename, p.filename]))
379 stdout.append('Copied %s -> %s' % (p.source_filename, p.filename))
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000380 if p.diff_hunks:
maruel@chromium.orgec4a9182012-09-28 20:39:45 +0000381 cmd = [
382 'patch',
383 '-p%s' % p.patchlevel,
384 '--forward',
385 '--force',
386 '--no-backup-if-mismatch',
387 ]
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000388 stdout.append(
389 subprocess2.check_output(
maruel@chromium.org44b21b92012-11-08 19:37:08 +0000390 cmd, stdin=p.get(False), cwd=self.project_path))
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000391 elif p.is_new and not os.path.exists(filepath):
392 # There is only a header. Just create the file if it doesn't
393 # exist.
394 open(filepath, 'w').close()
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000395 stdout.append('Created an empty file.')
maruel@chromium.org3da83172012-05-07 16:17:20 +0000396 if p.is_new and not p.source_filename:
397 # Do not run it if p.source_filename is defined, since svn copy was
398 # using above.
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000399 stdout.append(
400 self._check_output_svn(
401 ['add', p.filename, '--force'], credentials=False))
maruel@chromium.orgd7ca6162012-08-29 17:22:22 +0000402 for name, value in p.svn_properties:
403 if value is None:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000404 stdout.append(
405 self._check_output_svn(
406 ['propdel', '--quiet', name, p.filename],
407 credentials=False))
408 stdout.append('Property %s deleted.' % name)
maruel@chromium.orgd7ca6162012-08-29 17:22:22 +0000409 else:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000410 stdout.append(
411 self._check_output_svn(
412 ['propset', name, value, p.filename], credentials=False))
413 stdout.append('Property %s=%s' % (name, value))
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000414 for prop, values in self.svn_config.auto_props.iteritems():
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000415 if fnmatch.fnmatch(p.filename, prop):
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000416 for value in values.split(';'):
417 if '=' not in value:
maruel@chromium.orge1a03762012-09-24 15:28:52 +0000418 params = [value, '.']
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000419 else:
420 params = value.split('=', 1)
maruel@chromium.orge1a03762012-09-24 15:28:52 +0000421 if params[1] == '*':
422 # Works around crbug.com/150960 on Windows.
423 params[1] = '.'
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000424 stdout.append(
425 self._check_output_svn(
426 ['propset'] + params + [p.filename], credentials=False))
427 stdout.append('Property (auto) %s' % '='.join(params))
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000428 for post in post_processors:
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000429 post(self, p)
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000430 if verbose:
431 print p.filename
432 print align_stdout(stdout)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000433 except OSError, e:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000434 raise PatchApplicationFailed(p, '%s%s' % (align_stdout(stdout), e))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000435 except subprocess.CalledProcessError, e:
436 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000437 p,
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000438 'While running %s;\n%s%s' % (
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000439 ' '.join(e.cmd),
440 align_stdout(stdout),
441 align_stdout([getattr(e, 'stdout', '')])))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000442
443 def commit(self, commit_message, user):
444 logging.info('Committing patch for %s' % user)
445 assert self.commit_user
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000446 assert isinstance(commit_message, unicode)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000447 handle, commit_filename = tempfile.mkstemp(text=True)
448 try:
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000449 # Shouldn't assume default encoding is UTF-8. But really, if you are using
450 # anything else, you are living in another world.
451 os.write(handle, commit_message.encode('utf-8'))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000452 os.close(handle)
453 # When committing, svn won't update the Revision metadata of the checkout,
454 # so if svn commit returns "Committed revision 3.", svn info will still
455 # return "Revision: 2". Since running svn update right after svn commit
456 # creates a race condition with other committers, this code _must_ parse
457 # the output of svn commit and use a regexp to grab the revision number.
458 # Note that "Committed revision N." is localized but subprocess2 forces
459 # LANGUAGE=en.
460 args = ['commit', '--file', commit_filename]
461 # realauthor is parsed by a server-side hook.
462 if user and user != self.commit_user:
463 args.extend(['--with-revprop', 'realauthor=%s' % user])
464 out = self._check_output_svn(args)
465 finally:
466 os.remove(commit_filename)
467 lines = filter(None, out.splitlines())
468 match = re.match(r'^Committed revision (\d+).$', lines[-1])
469 if not match:
470 raise PatchApplicationFailed(
471 None,
472 'Couldn\'t make sense out of svn commit message:\n' + out)
473 return int(match.group(1))
474
maruel@chromium.org51919772011-06-12 01:27:42 +0000475 def _revert(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000476 """Reverts local modifications or checks out if the directory is not
477 present. Use depot_tools's functionality to do this.
478 """
479 flags = ['--ignore-externals']
maruel@chromium.org51919772011-06-12 01:27:42 +0000480 if revision:
481 flags.extend(['--revision', str(revision)])
maruel@chromium.org6e904b42012-12-19 14:21:14 +0000482 if os.path.isdir(self.project_path):
483 # This may remove any part (or all) of the checkout.
484 scm.SVN.Revert(self.project_path, no_ignore=True)
485
486 if os.path.isdir(self.project_path):
487 # Revive files that were deleted in scm.SVN.Revert().
488 self._check_call_svn(['update', '--force'] + flags)
489 else:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000490 logging.info(
491 'Directory %s is not present, checking it out.' % self.project_path)
492 self._check_call_svn(
maruel@chromium.org44b21b92012-11-08 19:37:08 +0000493 ['checkout', self.svn_url, self.project_path] + flags, cwd=None)
maruel@chromium.org51919772011-06-12 01:27:42 +0000494 return self._get_revision()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000495
maruel@chromium.org51919772011-06-12 01:27:42 +0000496 def _get_revision(self):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000497 out = self._check_output_svn(['info', '.'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000498 revision = int(self._parse_svn_info(out, 'revision'))
499 if revision != self._last_seen_revision:
500 logging.info('Updated to revision %d' % revision)
501 self._last_seen_revision = revision
502 return revision
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000503
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000504 def revisions(self, rev1, rev2):
505 """Returns the number of actual commits, not just the difference between
506 numbers.
507 """
508 rev2 = rev2 or 'HEAD'
509 # Revision range is inclusive and ordering doesn't matter, they'll appear in
510 # the order specified.
511 try:
512 out = self._check_output_svn(
513 ['log', '-q', self.svn_url, '-r', '%s:%s' % (rev1, rev2)])
514 except subprocess.CalledProcessError:
515 return None
516 # Ignore the '----' lines.
517 return len([l for l in out.splitlines() if l.startswith('r')]) - 1
518
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000519
520class GitCheckoutBase(CheckoutBase):
521 """Base class for git checkout. Not to be used as-is."""
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000522 def __init__(self, root_dir, project_name, remote_branch,
523 post_processors=None):
524 super(GitCheckoutBase, self).__init__(
525 root_dir, project_name, post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000526 # There is no reason to not hardcode it.
527 self.remote = 'origin'
528 self.remote_branch = remote_branch
529 self.working_branch = 'working_branch'
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000530
maruel@chromium.org51919772011-06-12 01:27:42 +0000531 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000532 """Resets the git repository in a clean state.
533
534 Checks it out if not present and deletes the working branch.
535 """
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000536 assert self.remote_branch
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000537 assert os.path.isdir(self.project_path)
538 self._check_call_git(['reset', '--hard', '--quiet'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000539 if revision:
540 try:
541 revision = self._check_output_git(['rev-parse', revision])
542 except subprocess.CalledProcessError:
maruel@chromium.org44b21b92012-11-08 19:37:08 +0000543 self._check_call_git(
544 ['fetch', self.remote, self.remote_branch, '--quiet'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000545 revision = self._check_output_git(['rev-parse', revision])
546 self._check_call_git(['checkout', '--force', '--quiet', revision])
547 else:
548 branches, active = self._branches()
549 if active != 'master':
550 self._check_call_git(['checkout', '--force', '--quiet', 'master'])
551 self._check_call_git(['pull', self.remote, self.remote_branch, '--quiet'])
552 if self.working_branch in branches:
553 self._call_git(['branch', '-D', self.working_branch])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000554
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000555 def apply_patch(self, patches, post_processors=None, verbose=False):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000556 """Applies a patch on 'working_branch' and switch to it.
557
558 Also commits the changes on the local branch.
559
560 Ignores svn properties and raise an exception on unexpected ones.
561 """
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000562 post_processors = post_processors or self.post_processors or []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000563 # It this throws, the checkout is corrupted. Maybe worth deleting it and
564 # trying again?
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000565 if self.remote_branch:
566 self._check_call_git(
567 ['checkout', '-b', self.working_branch,
568 '%s/%s' % (self.remote, self.remote_branch), '--quiet'])
maruel@chromium.org5e975632011-09-29 18:07:06 +0000569 for index, p in enumerate(patches):
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000570 stdout = []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000571 try:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000572 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000573 if p.is_delete:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000574 if (not os.path.exists(filepath) and
maruel@chromium.org5e975632011-09-29 18:07:06 +0000575 any(p1.source_filename == p.filename for p1 in patches[0:index])):
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000576 # The file was already deleted if a prior patch with file rename
577 # was already processed because 'git apply' did it for us.
maruel@chromium.org5e975632011-09-29 18:07:06 +0000578 pass
579 else:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000580 stdout.append(self._check_output_git(['rm', p.filename]))
581 stdout.append('Deleted.')
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000582 else:
583 dirname = os.path.dirname(p.filename)
584 full_dir = os.path.join(self.project_path, dirname)
585 if dirname and not os.path.isdir(full_dir):
586 os.makedirs(full_dir)
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000587 stdout.append('Created missing directory %s.' % dirname)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000588 if p.is_binary:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000589 content = p.get()
590 with open(filepath, 'wb') as f:
591 f.write(content)
592 stdout.append('Added binary file %d bytes' % len(content))
593 cmd = ['add', p.filename]
594 if verbose:
595 cmd.append('--verbose')
596 stdout.append(self._check_output_git(cmd))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000597 else:
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000598 # No need to do anything special with p.is_new or if not
599 # p.diff_hunks. git apply manages all that already.
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000600 cmd = ['apply', '--index', '-p%s' % p.patchlevel]
601 if verbose:
602 cmd.append('--verbose')
603 stdout.append(self._check_output_git(cmd, stdin=p.get(True)))
604 for name, value in p.svn_properties:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000605 # Ignore some known auto-props flags through .subversion/config,
606 # bails out on the other ones.
607 # TODO(maruel): Read ~/.subversion/config and detect the rules that
608 # applies here to figure out if the property will be correctly
609 # handled.
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000610 stdout.append('Property %s=%s' % (name, value))
maruel@chromium.orgd7ca6162012-08-29 17:22:22 +0000611 if not name in (
maruel@chromium.org9799a072012-01-11 00:26:25 +0000612 'svn:eol-style', 'svn:executable', 'svn:mime-type'):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000613 raise patch.UnsupportedPatchFormat(
614 p.filename,
615 'Cannot apply svn property %s to file %s.' % (
maruel@chromium.orgd7ca6162012-08-29 17:22:22 +0000616 name, p.filename))
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000617 for post in post_processors:
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000618 post(self, p)
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000619 if verbose:
620 print p.filename
621 print align_stdout(stdout)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000622 except OSError, e:
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000623 raise PatchApplicationFailed(p, '%s%s' % (align_stdout(stdout), e))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000624 except subprocess.CalledProcessError, e:
625 raise PatchApplicationFailed(
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000626 p,
627 'While running %s;\n%s%s' % (
628 ' '.join(e.cmd),
629 align_stdout(stdout),
630 align_stdout([getattr(e, 'stdout', '')])))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000631 # Once all the patches are processed and added to the index, commit the
632 # index.
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000633 cmd = ['commit', '-m', 'Committed patch']
634 if verbose:
635 cmd.append('--verbose')
636 self._check_call_git(cmd)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000637 # TODO(maruel): Weirdly enough they don't match, need to investigate.
638 #found_files = self._check_output_git(
639 # ['diff', 'master', '--name-only']).splitlines(False)
640 #assert sorted(patches.filenames) == sorted(found_files), (
641 # sorted(out), sorted(found_files))
642
643 def commit(self, commit_message, user):
644 """Updates the commit message.
645
646 Subclass needs to dcommit or push.
647 """
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000648 assert isinstance(commit_message, unicode)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000649 self._check_call_git(['commit', '--amend', '-m', commit_message])
650 return self._check_output_git(['rev-parse', 'HEAD']).strip()
651
652 def _check_call_git(self, args, **kwargs):
653 kwargs.setdefault('cwd', self.project_path)
654 kwargs.setdefault('stdout', self.VOID)
maruel@chromium.org44b21b92012-11-08 19:37:08 +0000655 return subprocess2.check_call_out(['git'] + args, **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000656
657 def _call_git(self, args, **kwargs):
658 """Like check_call but doesn't throw on failure."""
659 kwargs.setdefault('cwd', self.project_path)
660 kwargs.setdefault('stdout', self.VOID)
maruel@chromium.org44b21b92012-11-08 19:37:08 +0000661 return subprocess2.call(['git'] + args, **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000662
663 def _check_output_git(self, args, **kwargs):
664 kwargs.setdefault('cwd', self.project_path)
maruel@chromium.org87e6d332011-09-09 19:01:28 +0000665 return subprocess2.check_output(
maruel@chromium.org44b21b92012-11-08 19:37:08 +0000666 ['git'] + args, stderr=subprocess2.STDOUT, **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000667
668 def _branches(self):
669 """Returns the list of branches and the active one."""
670 out = self._check_output_git(['branch']).splitlines(False)
671 branches = [l[2:] for l in out]
672 active = None
673 for l in out:
674 if l.startswith('*'):
675 active = l[2:]
676 break
677 return branches, active
678
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000679 def revisions(self, rev1, rev2):
680 """Returns the number of actual commits between both hash."""
681 self._fetch_remote()
682
683 rev2 = rev2 or '%s/%s' % (self.remote, self.remote_branch)
684 # Revision range is ]rev1, rev2] and ordering matters.
685 try:
686 out = self._check_output_git(
687 ['log', '--format="%H"' , '%s..%s' % (rev1, rev2)])
688 except subprocess.CalledProcessError:
689 return None
690 return len(out.splitlines())
691
692 def _fetch_remote(self):
693 """Fetches the remote without rebasing."""
694 raise NotImplementedError()
695
696
697class GitCheckout(GitCheckoutBase):
698 """Git checkout implementation."""
699 def _fetch_remote(self):
700 # git fetch is always verbose even with -q -q so redirect its output.
maruel@chromium.org44b21b92012-11-08 19:37:08 +0000701 self._check_output_git(['fetch', self.remote, self.remote_branch])
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000702
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000703
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000704class ReadOnlyCheckout(object):
705 """Converts a checkout into a read-only one."""
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000706 def __init__(self, checkout, post_processors=None):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000707 super(ReadOnlyCheckout, self).__init__()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000708 self.checkout = checkout
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000709 self.post_processors = (post_processors or []) + (
710 self.checkout.post_processors or [])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000711
maruel@chromium.org51919772011-06-12 01:27:42 +0000712 def prepare(self, revision):
713 return self.checkout.prepare(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000714
715 def get_settings(self, key):
716 return self.checkout.get_settings(key)
717
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000718 def apply_patch(self, patches, post_processors=None, verbose=False):
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000719 return self.checkout.apply_patch(
maruel@chromium.org4dd9f722012-10-01 16:23:03 +0000720 patches, post_processors or self.post_processors, verbose)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000721
722 def commit(self, message, user): # pylint: disable=R0201
723 logging.info('Would have committed for %s with message: %s' % (
724 user, message))
725 return 'FAKE'
726
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000727 def revisions(self, rev1, rev2):
728 return self.checkout.revisions(rev1, rev2)
729
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000730 @property
731 def project_name(self):
732 return self.checkout.project_name
733
734 @property
735 def project_path(self):
736 return self.checkout.project_path