blob: 33c0c31a92e018429d7651f94c1fcd706dabbcfd [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
50class PatchApplicationFailed(Exception):
51 """Patch failed to be applied."""
maruel@chromium.org34f68552012-05-09 19:18:36 +000052 def __init__(self, p, status):
53 super(PatchApplicationFailed, self).__init__(p, status)
54 self.patch = p
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000055 self.status = status
56
maruel@chromium.org34f68552012-05-09 19:18:36 +000057 @property
58 def filename(self):
59 if self.patch:
60 return self.patch.filename
61
62 def __str__(self):
63 out = []
64 if self.filename:
65 out.append('Failed to apply patch for %s:' % self.filename)
66 if self.status:
67 out.append(self.status)
68 return '\n'.join(out)
69
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000070
71class CheckoutBase(object):
72 # Set to None to have verbose output.
73 VOID = subprocess2.VOID
74
maruel@chromium.org6ed8b502011-06-12 01:05:35 +000075 def __init__(self, root_dir, project_name, post_processors):
76 """
77 Args:
78 post_processor: list of lambda(checkout, patches) to call on each of the
79 modified files.
80 """
maruel@chromium.orga5129fb2011-06-20 18:36:25 +000081 super(CheckoutBase, self).__init__()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000082 self.root_dir = root_dir
83 self.project_name = project_name
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +000084 if self.project_name is None:
85 self.project_path = self.root_dir
86 else:
87 self.project_path = os.path.join(self.root_dir, self.project_name)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000088 # Only used for logging purposes.
89 self._last_seen_revision = None
maruel@chromium.orga5129fb2011-06-20 18:36:25 +000090 self.post_processors = post_processors
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000091 assert self.root_dir
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000092 assert self.project_path
93
94 def get_settings(self, key):
95 return get_code_review_setting(self.project_path, key)
96
maruel@chromium.org51919772011-06-12 01:27:42 +000097 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000098 """Checks out a clean copy of the tree and removes any local modification.
99
100 This function shouldn't throw unless the remote repository is inaccessible,
101 there is no free disk space or hard issues like that.
maruel@chromium.org51919772011-06-12 01:27:42 +0000102
103 Args:
104 revision: The revision it should sync to, SCM specific.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000105 """
106 raise NotImplementedError()
107
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000108 def apply_patch(self, patches, post_processors=None):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000109 """Applies a patch and returns the list of modified files.
110
111 This function should throw patch.UnsupportedPatchFormat or
112 PatchApplicationFailed when relevant.
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000113
114 Args:
115 patches: patch.PatchSet object.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000116 """
117 raise NotImplementedError()
118
119 def commit(self, commit_message, user):
120 """Commits the patch upstream, while impersonating 'user'."""
121 raise NotImplementedError()
122
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000123 def revisions(self, rev1, rev2):
124 """Returns the count of revisions from rev1 to rev2, e.g. len(]rev1, rev2]).
125
126 If rev2 is None, it means 'HEAD'.
127
128 Returns None if there is no link between the two.
129 """
130 raise NotImplementedError()
131
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000132
133class RawCheckout(CheckoutBase):
134 """Used to apply a patch locally without any intent to commit it.
135
136 To be used by the try server.
137 """
maruel@chromium.org51919772011-06-12 01:27:42 +0000138 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000139 """Stubbed out."""
140 pass
141
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000142 def apply_patch(self, patches, post_processors=None):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000143 """Ignores svn properties."""
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000144 post_processors = post_processors or self.post_processors or []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000145 for p in patches:
maruel@chromium.orgde800ff2012-09-12 19:25:24 +0000146 logging.debug('Applying %s' % p.filename)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000147 try:
148 stdout = ''
149 filename = os.path.join(self.project_path, p.filename)
150 if p.is_delete:
151 os.remove(filename)
152 else:
153 dirname = os.path.dirname(p.filename)
154 full_dir = os.path.join(self.project_path, dirname)
155 if dirname and not os.path.isdir(full_dir):
156 os.makedirs(full_dir)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000157
158 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000159 if p.is_binary:
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000160 with open(filepath, 'wb') as f:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000161 f.write(p.get())
162 else:
maruel@chromium.org5e975632011-09-29 18:07:06 +0000163 if p.source_filename:
164 if not p.is_new:
165 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000166 p,
maruel@chromium.org5e975632011-09-29 18:07:06 +0000167 'File has a source filename specified but is not new')
168 # Copy the file first.
169 if os.path.isfile(filepath):
170 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000171 p, 'File exist but was about to be overwriten')
maruel@chromium.org5e975632011-09-29 18:07:06 +0000172 shutil.copy2(
173 os.path.join(self.project_path, p.source_filename), filepath)
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000174 if p.diff_hunks:
175 stdout = subprocess2.check_output(
maruel@chromium.org5e975632011-09-29 18:07:06 +0000176 ['patch', '-u', '--binary', '-p%s' % p.patchlevel],
177 stdin=p.get(False),
maruel@chromium.org87e6d332011-09-09 19:01:28 +0000178 stderr=subprocess2.STDOUT,
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000179 cwd=self.project_path)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000180 elif p.is_new and not os.path.exists(filepath):
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000181 # There is only a header. Just create the file.
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000182 open(filepath, 'w').close()
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000183 for post in post_processors:
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000184 post(self, p)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000185 except OSError, e:
maruel@chromium.org34f68552012-05-09 19:18:36 +0000186 raise PatchApplicationFailed(p, '%s%s' % (stdout, e))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000187 except subprocess.CalledProcessError, e:
188 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000189 p, '%s%s' % (stdout, getattr(e, 'stdout', None)))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000190
191 def commit(self, commit_message, user):
192 """Stubbed out."""
193 raise NotImplementedError('RawCheckout can\'t commit')
194
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000195 def revisions(self, _rev1, _rev2):
196 return None
197
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000198
199class SvnConfig(object):
200 """Parses a svn configuration file."""
201 def __init__(self, svn_config_dir=None):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000202 super(SvnConfig, self).__init__()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000203 self.svn_config_dir = svn_config_dir
204 self.default = not bool(self.svn_config_dir)
205 if not self.svn_config_dir:
206 if sys.platform == 'win32':
207 self.svn_config_dir = os.path.join(os.environ['APPDATA'], 'Subversion')
208 else:
209 self.svn_config_dir = os.path.join(os.environ['HOME'], '.subversion')
210 svn_config_file = os.path.join(self.svn_config_dir, 'config')
211 parser = ConfigParser.SafeConfigParser()
212 if os.path.isfile(svn_config_file):
213 parser.read(svn_config_file)
214 else:
215 parser.add_section('auto-props')
216 self.auto_props = dict(parser.items('auto-props'))
217
218
219class SvnMixIn(object):
220 """MixIn class to add svn commands common to both svn and git-svn clients."""
221 # These members need to be set by the subclass.
222 commit_user = None
223 commit_pwd = None
224 svn_url = None
225 project_path = None
226 # Override at class level when necessary. If used, --non-interactive is
227 # implied.
228 svn_config = SvnConfig()
229 # Set to True when non-interactivity is necessary but a custom subversion
230 # configuration directory is not necessary.
231 non_interactive = False
232
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000233 def _add_svn_flags(self, args, non_interactive, credentials=True):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000234 args = ['svn'] + args
235 if not self.svn_config.default:
236 args.extend(['--config-dir', self.svn_config.svn_config_dir])
237 if not self.svn_config.default or self.non_interactive or non_interactive:
238 args.append('--non-interactive')
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000239 if credentials:
240 if self.commit_user:
241 args.extend(['--username', self.commit_user])
242 if self.commit_pwd:
243 args.extend(['--password', self.commit_pwd])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000244 return args
245
246 def _check_call_svn(self, args, **kwargs):
247 """Runs svn and throws an exception if the command failed."""
248 kwargs.setdefault('cwd', self.project_path)
249 kwargs.setdefault('stdout', self.VOID)
maruel@chromium.org0bcd1d32011-04-26 15:55:49 +0000250 return subprocess2.check_call_out(
251 self._add_svn_flags(args, False), **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000252
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000253 def _check_output_svn(self, args, credentials=True, **kwargs):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000254 """Runs svn and throws an exception if the command failed.
255
256 Returns the output.
257 """
258 kwargs.setdefault('cwd', self.project_path)
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000259 return subprocess2.check_output(
maruel@chromium.org87e6d332011-09-09 19:01:28 +0000260 self._add_svn_flags(args, True, credentials),
261 stderr=subprocess2.STDOUT,
262 **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000263
264 @staticmethod
265 def _parse_svn_info(output, key):
266 """Returns value for key from svn info output.
267
268 Case insensitive.
269 """
270 values = {}
271 key = key.lower()
272 for line in output.splitlines(False):
273 if not line:
274 continue
275 k, v = line.split(':', 1)
276 k = k.strip().lower()
277 v = v.strip()
278 assert not k in values
279 values[k] = v
280 return values.get(key, None)
281
282
283class SvnCheckout(CheckoutBase, SvnMixIn):
284 """Manages a subversion checkout."""
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000285 def __init__(self, root_dir, project_name, commit_user, commit_pwd, svn_url,
286 post_processors=None):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000287 CheckoutBase.__init__(self, root_dir, project_name, post_processors)
288 SvnMixIn.__init__(self)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000289 self.commit_user = commit_user
290 self.commit_pwd = commit_pwd
291 self.svn_url = svn_url
292 assert bool(self.commit_user) >= bool(self.commit_pwd)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000293
maruel@chromium.org51919772011-06-12 01:27:42 +0000294 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000295 # Will checkout if the directory is not present.
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000296 assert self.svn_url
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000297 if not os.path.isdir(self.project_path):
298 logging.info('Checking out %s in %s' %
299 (self.project_name, self.project_path))
maruel@chromium.org51919772011-06-12 01:27:42 +0000300 return self._revert(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000301
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000302 def apply_patch(self, patches, post_processors=None):
303 post_processors = post_processors or self.post_processors or []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000304 for p in patches:
maruel@chromium.orgde800ff2012-09-12 19:25:24 +0000305 logging.debug('Applying %s' % p.filename)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000306 try:
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000307 # It is important to use credentials=False otherwise credentials could
308 # leak in the error message. Credentials are not necessary here for the
309 # following commands anyway.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000310 stdout = ''
311 if p.is_delete:
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000312 stdout += self._check_output_svn(
313 ['delete', p.filename, '--force'], credentials=False)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000314 else:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000315 # svn add while creating directories otherwise svn add on the
316 # contained files will silently fail.
317 # First, find the root directory that exists.
318 dirname = os.path.dirname(p.filename)
319 dirs_to_create = []
320 while (dirname and
321 not os.path.isdir(os.path.join(self.project_path, dirname))):
322 dirs_to_create.append(dirname)
323 dirname = os.path.dirname(dirname)
324 for dir_to_create in reversed(dirs_to_create):
325 os.mkdir(os.path.join(self.project_path, dir_to_create))
326 stdout += self._check_output_svn(
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000327 ['add', dir_to_create, '--force'], credentials=False)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000328
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000329 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000330 if p.is_binary:
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000331 with open(filepath, 'wb') as f:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000332 f.write(p.get())
333 else:
maruel@chromium.org5e975632011-09-29 18:07:06 +0000334 if p.source_filename:
335 if not p.is_new:
336 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000337 p,
maruel@chromium.org5e975632011-09-29 18:07:06 +0000338 'File has a source filename specified but is not new')
339 # Copy the file first.
340 if os.path.isfile(filepath):
341 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000342 p, 'File exist but was about to be overwriten')
maruel@chromium.org3da83172012-05-07 16:17:20 +0000343 self._check_output_svn(
344 [
345 'copy',
346 os.path.join(self.project_path, p.source_filename),
347 filepath
348 ])
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000349 if p.diff_hunks:
350 cmd = ['patch', '-p%s' % p.patchlevel, '--forward', '--force']
351 stdout += subprocess2.check_output(
maruel@chromium.org5e975632011-09-29 18:07:06 +0000352 cmd, stdin=p.get(False), cwd=self.project_path)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000353 elif p.is_new and not os.path.exists(filepath):
354 # There is only a header. Just create the file if it doesn't
355 # exist.
356 open(filepath, 'w').close()
maruel@chromium.org3da83172012-05-07 16:17:20 +0000357 if p.is_new and not p.source_filename:
358 # Do not run it if p.source_filename is defined, since svn copy was
359 # using above.
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000360 stdout += self._check_output_svn(
361 ['add', p.filename, '--force'], credentials=False)
maruel@chromium.orgd7ca6162012-08-29 17:22:22 +0000362 for name, value in p.svn_properties:
363 if value is None:
364 stdout += self._check_output_svn(
365 ['propdel', '--quiet', name, p.filename], credentials=False)
366 else:
367 stdout += self._check_output_svn(
368 ['propset', name, value, p.filename], credentials=False)
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000369 for prop, values in self.svn_config.auto_props.iteritems():
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000370 if fnmatch.fnmatch(p.filename, prop):
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000371 for value in values.split(';'):
372 if '=' not in value:
373 params = [value, '*']
374 else:
375 params = value.split('=', 1)
376 stdout += self._check_output_svn(
377 ['propset'] + params + [p.filename], credentials=False)
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000378 for post in post_processors:
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000379 post(self, p)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000380 except OSError, e:
maruel@chromium.org34f68552012-05-09 19:18:36 +0000381 raise PatchApplicationFailed(p, '%s%s' % (stdout, e))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000382 except subprocess.CalledProcessError, e:
383 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000384 p,
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000385 'While running %s;\n%s%s' % (
386 ' '.join(e.cmd), stdout, getattr(e, 'stdout', '')))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000387
388 def commit(self, commit_message, user):
389 logging.info('Committing patch for %s' % user)
390 assert self.commit_user
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000391 assert isinstance(commit_message, unicode)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000392 handle, commit_filename = tempfile.mkstemp(text=True)
393 try:
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000394 # Shouldn't assume default encoding is UTF-8. But really, if you are using
395 # anything else, you are living in another world.
396 os.write(handle, commit_message.encode('utf-8'))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000397 os.close(handle)
398 # When committing, svn won't update the Revision metadata of the checkout,
399 # so if svn commit returns "Committed revision 3.", svn info will still
400 # return "Revision: 2". Since running svn update right after svn commit
401 # creates a race condition with other committers, this code _must_ parse
402 # the output of svn commit and use a regexp to grab the revision number.
403 # Note that "Committed revision N." is localized but subprocess2 forces
404 # LANGUAGE=en.
405 args = ['commit', '--file', commit_filename]
406 # realauthor is parsed by a server-side hook.
407 if user and user != self.commit_user:
408 args.extend(['--with-revprop', 'realauthor=%s' % user])
409 out = self._check_output_svn(args)
410 finally:
411 os.remove(commit_filename)
412 lines = filter(None, out.splitlines())
413 match = re.match(r'^Committed revision (\d+).$', lines[-1])
414 if not match:
415 raise PatchApplicationFailed(
416 None,
417 'Couldn\'t make sense out of svn commit message:\n' + out)
418 return int(match.group(1))
419
maruel@chromium.org51919772011-06-12 01:27:42 +0000420 def _revert(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000421 """Reverts local modifications or checks out if the directory is not
422 present. Use depot_tools's functionality to do this.
423 """
424 flags = ['--ignore-externals']
maruel@chromium.org51919772011-06-12 01:27:42 +0000425 if revision:
426 flags.extend(['--revision', str(revision)])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000427 if not os.path.isdir(self.project_path):
428 logging.info(
429 'Directory %s is not present, checking it out.' % self.project_path)
430 self._check_call_svn(
431 ['checkout', self.svn_url, self.project_path] + flags, cwd=None)
432 else:
maruel@chromium.orgea15cb72012-05-04 14:16:31 +0000433 scm.SVN.Revert(self.project_path, no_ignore=True)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000434 # Revive files that were deleted in scm.SVN.Revert().
435 self._check_call_svn(['update', '--force'] + flags)
maruel@chromium.org51919772011-06-12 01:27:42 +0000436 return self._get_revision()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000437
maruel@chromium.org51919772011-06-12 01:27:42 +0000438 def _get_revision(self):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000439 out = self._check_output_svn(['info', '.'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000440 revision = int(self._parse_svn_info(out, 'revision'))
441 if revision != self._last_seen_revision:
442 logging.info('Updated to revision %d' % revision)
443 self._last_seen_revision = revision
444 return revision
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000445
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000446 def revisions(self, rev1, rev2):
447 """Returns the number of actual commits, not just the difference between
448 numbers.
449 """
450 rev2 = rev2 or 'HEAD'
451 # Revision range is inclusive and ordering doesn't matter, they'll appear in
452 # the order specified.
453 try:
454 out = self._check_output_svn(
455 ['log', '-q', self.svn_url, '-r', '%s:%s' % (rev1, rev2)])
456 except subprocess.CalledProcessError:
457 return None
458 # Ignore the '----' lines.
459 return len([l for l in out.splitlines() if l.startswith('r')]) - 1
460
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000461
462class GitCheckoutBase(CheckoutBase):
463 """Base class for git checkout. Not to be used as-is."""
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000464 def __init__(self, root_dir, project_name, remote_branch,
465 post_processors=None):
466 super(GitCheckoutBase, self).__init__(
467 root_dir, project_name, post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000468 # There is no reason to not hardcode it.
469 self.remote = 'origin'
470 self.remote_branch = remote_branch
471 self.working_branch = 'working_branch'
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000472
maruel@chromium.org51919772011-06-12 01:27:42 +0000473 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000474 """Resets the git repository in a clean state.
475
476 Checks it out if not present and deletes the working branch.
477 """
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000478 assert self.remote_branch
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000479 assert os.path.isdir(self.project_path)
480 self._check_call_git(['reset', '--hard', '--quiet'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000481 if revision:
482 try:
483 revision = self._check_output_git(['rev-parse', revision])
484 except subprocess.CalledProcessError:
485 self._check_call_git(
486 ['fetch', self.remote, self.remote_branch, '--quiet'])
487 revision = self._check_output_git(['rev-parse', revision])
488 self._check_call_git(['checkout', '--force', '--quiet', revision])
489 else:
490 branches, active = self._branches()
491 if active != 'master':
492 self._check_call_git(['checkout', '--force', '--quiet', 'master'])
493 self._check_call_git(['pull', self.remote, self.remote_branch, '--quiet'])
494 if self.working_branch in branches:
495 self._call_git(['branch', '-D', self.working_branch])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000496
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000497 def apply_patch(self, patches, post_processors=None):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000498 """Applies a patch on 'working_branch' and switch to it.
499
500 Also commits the changes on the local branch.
501
502 Ignores svn properties and raise an exception on unexpected ones.
503 """
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000504 post_processors = post_processors or self.post_processors or []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000505 # It this throws, the checkout is corrupted. Maybe worth deleting it and
506 # trying again?
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000507 if self.remote_branch:
508 self._check_call_git(
509 ['checkout', '-b', self.working_branch,
510 '%s/%s' % (self.remote, self.remote_branch), '--quiet'])
maruel@chromium.org5e975632011-09-29 18:07:06 +0000511 for index, p in enumerate(patches):
maruel@chromium.orgde800ff2012-09-12 19:25:24 +0000512 logging.debug('Applying %s' % p.filename)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000513 try:
514 stdout = ''
515 if p.is_delete:
maruel@chromium.org5e975632011-09-29 18:07:06 +0000516 if (not os.path.exists(p.filename) and
517 any(p1.source_filename == p.filename for p1 in patches[0:index])):
518 # The file could already be deleted if a prior patch with file
519 # rename was already processed. To be sure, look at all the previous
520 # patches to see if they were a file rename.
521 pass
522 else:
523 stdout += self._check_output_git(['rm', p.filename])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000524 else:
525 dirname = os.path.dirname(p.filename)
526 full_dir = os.path.join(self.project_path, dirname)
527 if dirname and not os.path.isdir(full_dir):
528 os.makedirs(full_dir)
529 if p.is_binary:
530 with open(os.path.join(self.project_path, p.filename), 'wb') as f:
531 f.write(p.get())
532 stdout += self._check_output_git(['add', p.filename])
533 else:
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000534 # No need to do anything special with p.is_new or if not
535 # p.diff_hunks. git apply manages all that already.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000536 stdout += self._check_output_git(
maruel@chromium.org5e975632011-09-29 18:07:06 +0000537 ['apply', '--index', '-p%s' % p.patchlevel], stdin=p.get(True))
maruel@chromium.orgd7ca6162012-08-29 17:22:22 +0000538 for name, _ in p.svn_properties:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000539 # Ignore some known auto-props flags through .subversion/config,
540 # bails out on the other ones.
541 # TODO(maruel): Read ~/.subversion/config and detect the rules that
542 # applies here to figure out if the property will be correctly
543 # handled.
maruel@chromium.orgd7ca6162012-08-29 17:22:22 +0000544 if not name in (
maruel@chromium.org9799a072012-01-11 00:26:25 +0000545 'svn:eol-style', 'svn:executable', 'svn:mime-type'):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000546 raise patch.UnsupportedPatchFormat(
547 p.filename,
548 'Cannot apply svn property %s to file %s.' % (
maruel@chromium.orgd7ca6162012-08-29 17:22:22 +0000549 name, p.filename))
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000550 for post in post_processors:
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000551 post(self, p)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000552 except OSError, e:
maruel@chromium.org34f68552012-05-09 19:18:36 +0000553 raise PatchApplicationFailed(p, '%s%s' % (stdout, e))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000554 except subprocess.CalledProcessError, e:
555 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000556 p, '%s%s' % (stdout, getattr(e, 'stdout', None)))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000557 # Once all the patches are processed and added to the index, commit the
558 # index.
559 self._check_call_git(['commit', '-m', 'Committed patch'])
560 # TODO(maruel): Weirdly enough they don't match, need to investigate.
561 #found_files = self._check_output_git(
562 # ['diff', 'master', '--name-only']).splitlines(False)
563 #assert sorted(patches.filenames) == sorted(found_files), (
564 # sorted(out), sorted(found_files))
565
566 def commit(self, commit_message, user):
567 """Updates the commit message.
568
569 Subclass needs to dcommit or push.
570 """
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000571 assert isinstance(commit_message, unicode)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000572 self._check_call_git(['commit', '--amend', '-m', commit_message])
573 return self._check_output_git(['rev-parse', 'HEAD']).strip()
574
575 def _check_call_git(self, args, **kwargs):
576 kwargs.setdefault('cwd', self.project_path)
577 kwargs.setdefault('stdout', self.VOID)
maruel@chromium.org0bcd1d32011-04-26 15:55:49 +0000578 return subprocess2.check_call_out(['git'] + args, **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000579
580 def _call_git(self, args, **kwargs):
581 """Like check_call but doesn't throw on failure."""
582 kwargs.setdefault('cwd', self.project_path)
583 kwargs.setdefault('stdout', self.VOID)
584 return subprocess2.call(['git'] + args, **kwargs)
585
586 def _check_output_git(self, args, **kwargs):
587 kwargs.setdefault('cwd', self.project_path)
maruel@chromium.org87e6d332011-09-09 19:01:28 +0000588 return subprocess2.check_output(
589 ['git'] + args, stderr=subprocess2.STDOUT, **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000590
591 def _branches(self):
592 """Returns the list of branches and the active one."""
593 out = self._check_output_git(['branch']).splitlines(False)
594 branches = [l[2:] for l in out]
595 active = None
596 for l in out:
597 if l.startswith('*'):
598 active = l[2:]
599 break
600 return branches, active
601
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000602 def revisions(self, rev1, rev2):
603 """Returns the number of actual commits between both hash."""
604 self._fetch_remote()
605
606 rev2 = rev2 or '%s/%s' % (self.remote, self.remote_branch)
607 # Revision range is ]rev1, rev2] and ordering matters.
608 try:
609 out = self._check_output_git(
610 ['log', '--format="%H"' , '%s..%s' % (rev1, rev2)])
611 except subprocess.CalledProcessError:
612 return None
613 return len(out.splitlines())
614
615 def _fetch_remote(self):
616 """Fetches the remote without rebasing."""
617 raise NotImplementedError()
618
619
620class GitCheckout(GitCheckoutBase):
621 """Git checkout implementation."""
622 def _fetch_remote(self):
623 # git fetch is always verbose even with -q -q so redirect its output.
624 self._check_output_git(['fetch', self.remote, self.remote_branch])
625
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000626
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000627class ReadOnlyCheckout(object):
628 """Converts a checkout into a read-only one."""
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000629 def __init__(self, checkout, post_processors=None):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000630 super(ReadOnlyCheckout, self).__init__()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000631 self.checkout = checkout
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000632 self.post_processors = (post_processors or []) + (
633 self.checkout.post_processors or [])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000634
maruel@chromium.org51919772011-06-12 01:27:42 +0000635 def prepare(self, revision):
636 return self.checkout.prepare(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000637
638 def get_settings(self, key):
639 return self.checkout.get_settings(key)
640
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000641 def apply_patch(self, patches, post_processors=None):
642 return self.checkout.apply_patch(
643 patches, post_processors or self.post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000644
645 def commit(self, message, user): # pylint: disable=R0201
646 logging.info('Would have committed for %s with message: %s' % (
647 user, message))
648 return 'FAKE'
649
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000650 def revisions(self, rev1, rev2):
651 return self.checkout.revisions(rev1, rev2)
652
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000653 @property
654 def project_name(self):
655 return self.checkout.project_name
656
657 @property
658 def project_path(self):
659 return self.checkout.project_path