blob: c6743479da4d4424c591d86f55fede84a50c0cb3 [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:
146 try:
147 stdout = ''
148 filename = os.path.join(self.project_path, p.filename)
149 if p.is_delete:
150 os.remove(filename)
151 else:
152 dirname = os.path.dirname(p.filename)
153 full_dir = os.path.join(self.project_path, dirname)
154 if dirname and not os.path.isdir(full_dir):
155 os.makedirs(full_dir)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000156
157 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000158 if p.is_binary:
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000159 with open(filepath, 'wb') as f:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000160 f.write(p.get())
161 else:
maruel@chromium.org5e975632011-09-29 18:07:06 +0000162 if p.source_filename:
163 if not p.is_new:
164 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000165 p,
maruel@chromium.org5e975632011-09-29 18:07:06 +0000166 'File has a source filename specified but is not new')
167 # Copy the file first.
168 if os.path.isfile(filepath):
169 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000170 p, 'File exist but was about to be overwriten')
maruel@chromium.org5e975632011-09-29 18:07:06 +0000171 shutil.copy2(
172 os.path.join(self.project_path, p.source_filename), filepath)
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000173 if p.diff_hunks:
174 stdout = subprocess2.check_output(
maruel@chromium.org5e975632011-09-29 18:07:06 +0000175 ['patch', '-u', '--binary', '-p%s' % p.patchlevel],
176 stdin=p.get(False),
maruel@chromium.org87e6d332011-09-09 19:01:28 +0000177 stderr=subprocess2.STDOUT,
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000178 cwd=self.project_path)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000179 elif p.is_new and not os.path.exists(filepath):
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000180 # There is only a header. Just create the file.
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000181 open(filepath, 'w').close()
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000182 for post in post_processors:
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000183 post(self, p)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000184 except OSError, e:
maruel@chromium.org34f68552012-05-09 19:18:36 +0000185 raise PatchApplicationFailed(p, '%s%s' % (stdout, e))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000186 except subprocess.CalledProcessError, e:
187 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000188 p, '%s%s' % (stdout, getattr(e, 'stdout', None)))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000189
190 def commit(self, commit_message, user):
191 """Stubbed out."""
192 raise NotImplementedError('RawCheckout can\'t commit')
193
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000194 def revisions(self, _rev1, _rev2):
195 return None
196
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000197
198class SvnConfig(object):
199 """Parses a svn configuration file."""
200 def __init__(self, svn_config_dir=None):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000201 super(SvnConfig, self).__init__()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000202 self.svn_config_dir = svn_config_dir
203 self.default = not bool(self.svn_config_dir)
204 if not self.svn_config_dir:
205 if sys.platform == 'win32':
206 self.svn_config_dir = os.path.join(os.environ['APPDATA'], 'Subversion')
207 else:
208 self.svn_config_dir = os.path.join(os.environ['HOME'], '.subversion')
209 svn_config_file = os.path.join(self.svn_config_dir, 'config')
210 parser = ConfigParser.SafeConfigParser()
211 if os.path.isfile(svn_config_file):
212 parser.read(svn_config_file)
213 else:
214 parser.add_section('auto-props')
215 self.auto_props = dict(parser.items('auto-props'))
216
217
218class SvnMixIn(object):
219 """MixIn class to add svn commands common to both svn and git-svn clients."""
220 # These members need to be set by the subclass.
221 commit_user = None
222 commit_pwd = None
223 svn_url = None
224 project_path = None
225 # Override at class level when necessary. If used, --non-interactive is
226 # implied.
227 svn_config = SvnConfig()
228 # Set to True when non-interactivity is necessary but a custom subversion
229 # configuration directory is not necessary.
230 non_interactive = False
231
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000232 def _add_svn_flags(self, args, non_interactive, credentials=True):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000233 args = ['svn'] + args
234 if not self.svn_config.default:
235 args.extend(['--config-dir', self.svn_config.svn_config_dir])
236 if not self.svn_config.default or self.non_interactive or non_interactive:
237 args.append('--non-interactive')
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000238 if credentials:
239 if self.commit_user:
240 args.extend(['--username', self.commit_user])
241 if self.commit_pwd:
242 args.extend(['--password', self.commit_pwd])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000243 return args
244
245 def _check_call_svn(self, args, **kwargs):
246 """Runs svn and throws an exception if the command failed."""
247 kwargs.setdefault('cwd', self.project_path)
248 kwargs.setdefault('stdout', self.VOID)
maruel@chromium.org0bcd1d32011-04-26 15:55:49 +0000249 return subprocess2.check_call_out(
250 self._add_svn_flags(args, False), **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000251
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000252 def _check_output_svn(self, args, credentials=True, **kwargs):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000253 """Runs svn and throws an exception if the command failed.
254
255 Returns the output.
256 """
257 kwargs.setdefault('cwd', self.project_path)
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000258 return subprocess2.check_output(
maruel@chromium.org87e6d332011-09-09 19:01:28 +0000259 self._add_svn_flags(args, True, credentials),
260 stderr=subprocess2.STDOUT,
261 **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000262
263 @staticmethod
264 def _parse_svn_info(output, key):
265 """Returns value for key from svn info output.
266
267 Case insensitive.
268 """
269 values = {}
270 key = key.lower()
271 for line in output.splitlines(False):
272 if not line:
273 continue
274 k, v = line.split(':', 1)
275 k = k.strip().lower()
276 v = v.strip()
277 assert not k in values
278 values[k] = v
279 return values.get(key, None)
280
281
282class SvnCheckout(CheckoutBase, SvnMixIn):
283 """Manages a subversion checkout."""
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000284 def __init__(self, root_dir, project_name, commit_user, commit_pwd, svn_url,
285 post_processors=None):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000286 CheckoutBase.__init__(self, root_dir, project_name, post_processors)
287 SvnMixIn.__init__(self)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000288 self.commit_user = commit_user
289 self.commit_pwd = commit_pwd
290 self.svn_url = svn_url
291 assert bool(self.commit_user) >= bool(self.commit_pwd)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000292
maruel@chromium.org51919772011-06-12 01:27:42 +0000293 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000294 # Will checkout if the directory is not present.
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000295 assert self.svn_url
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000296 if not os.path.isdir(self.project_path):
297 logging.info('Checking out %s in %s' %
298 (self.project_name, self.project_path))
maruel@chromium.org51919772011-06-12 01:27:42 +0000299 return self._revert(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000300
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000301 def apply_patch(self, patches, post_processors=None):
302 post_processors = post_processors or self.post_processors or []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000303 for p in patches:
304 try:
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000305 # It is important to use credentials=False otherwise credentials could
306 # leak in the error message. Credentials are not necessary here for the
307 # following commands anyway.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000308 stdout = ''
309 if p.is_delete:
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000310 stdout += self._check_output_svn(
311 ['delete', p.filename, '--force'], credentials=False)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000312 else:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000313 # svn add while creating directories otherwise svn add on the
314 # contained files will silently fail.
315 # First, find the root directory that exists.
316 dirname = os.path.dirname(p.filename)
317 dirs_to_create = []
318 while (dirname and
319 not os.path.isdir(os.path.join(self.project_path, dirname))):
320 dirs_to_create.append(dirname)
321 dirname = os.path.dirname(dirname)
322 for dir_to_create in reversed(dirs_to_create):
323 os.mkdir(os.path.join(self.project_path, dir_to_create))
324 stdout += self._check_output_svn(
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000325 ['add', dir_to_create, '--force'], credentials=False)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000326
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000327 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000328 if p.is_binary:
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000329 with open(filepath, 'wb') as f:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000330 f.write(p.get())
331 else:
maruel@chromium.org5e975632011-09-29 18:07:06 +0000332 if p.source_filename:
333 if not p.is_new:
334 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000335 p,
maruel@chromium.org5e975632011-09-29 18:07:06 +0000336 'File has a source filename specified but is not new')
337 # Copy the file first.
338 if os.path.isfile(filepath):
339 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000340 p, 'File exist but was about to be overwriten')
maruel@chromium.org3da83172012-05-07 16:17:20 +0000341 self._check_output_svn(
342 [
343 'copy',
344 os.path.join(self.project_path, p.source_filename),
345 filepath
346 ])
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000347 if p.diff_hunks:
348 cmd = ['patch', '-p%s' % p.patchlevel, '--forward', '--force']
349 stdout += subprocess2.check_output(
maruel@chromium.org5e975632011-09-29 18:07:06 +0000350 cmd, stdin=p.get(False), cwd=self.project_path)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000351 elif p.is_new and not os.path.exists(filepath):
352 # There is only a header. Just create the file if it doesn't
353 # exist.
354 open(filepath, 'w').close()
maruel@chromium.org3da83172012-05-07 16:17:20 +0000355 if p.is_new and not p.source_filename:
356 # Do not run it if p.source_filename is defined, since svn copy was
357 # using above.
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000358 stdout += self._check_output_svn(
359 ['add', p.filename, '--force'], credentials=False)
maruel@chromium.orgd7ca6162012-08-29 17:22:22 +0000360 for name, value in p.svn_properties:
361 if value is None:
362 stdout += self._check_output_svn(
363 ['propdel', '--quiet', name, p.filename], credentials=False)
364 else:
365 stdout += self._check_output_svn(
366 ['propset', name, value, p.filename], credentials=False)
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000367 for prop, values in self.svn_config.auto_props.iteritems():
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000368 if fnmatch.fnmatch(p.filename, prop):
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000369 for value in values.split(';'):
370 if '=' not in value:
371 params = [value, '*']
372 else:
373 params = value.split('=', 1)
374 stdout += self._check_output_svn(
375 ['propset'] + params + [p.filename], credentials=False)
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000376 for post in post_processors:
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000377 post(self, p)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000378 except OSError, e:
maruel@chromium.org34f68552012-05-09 19:18:36 +0000379 raise PatchApplicationFailed(p, '%s%s' % (stdout, e))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000380 except subprocess.CalledProcessError, e:
381 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000382 p,
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000383 'While running %s;\n%s%s' % (
384 ' '.join(e.cmd), stdout, getattr(e, 'stdout', '')))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000385
386 def commit(self, commit_message, user):
387 logging.info('Committing patch for %s' % user)
388 assert self.commit_user
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000389 assert isinstance(commit_message, unicode)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000390 handle, commit_filename = tempfile.mkstemp(text=True)
391 try:
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000392 # Shouldn't assume default encoding is UTF-8. But really, if you are using
393 # anything else, you are living in another world.
394 os.write(handle, commit_message.encode('utf-8'))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000395 os.close(handle)
396 # When committing, svn won't update the Revision metadata of the checkout,
397 # so if svn commit returns "Committed revision 3.", svn info will still
398 # return "Revision: 2". Since running svn update right after svn commit
399 # creates a race condition with other committers, this code _must_ parse
400 # the output of svn commit and use a regexp to grab the revision number.
401 # Note that "Committed revision N." is localized but subprocess2 forces
402 # LANGUAGE=en.
403 args = ['commit', '--file', commit_filename]
404 # realauthor is parsed by a server-side hook.
405 if user and user != self.commit_user:
406 args.extend(['--with-revprop', 'realauthor=%s' % user])
407 out = self._check_output_svn(args)
408 finally:
409 os.remove(commit_filename)
410 lines = filter(None, out.splitlines())
411 match = re.match(r'^Committed revision (\d+).$', lines[-1])
412 if not match:
413 raise PatchApplicationFailed(
414 None,
415 'Couldn\'t make sense out of svn commit message:\n' + out)
416 return int(match.group(1))
417
maruel@chromium.org51919772011-06-12 01:27:42 +0000418 def _revert(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000419 """Reverts local modifications or checks out if the directory is not
420 present. Use depot_tools's functionality to do this.
421 """
422 flags = ['--ignore-externals']
maruel@chromium.org51919772011-06-12 01:27:42 +0000423 if revision:
424 flags.extend(['--revision', str(revision)])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000425 if not os.path.isdir(self.project_path):
426 logging.info(
427 'Directory %s is not present, checking it out.' % self.project_path)
428 self._check_call_svn(
429 ['checkout', self.svn_url, self.project_path] + flags, cwd=None)
430 else:
maruel@chromium.orgea15cb72012-05-04 14:16:31 +0000431 scm.SVN.Revert(self.project_path, no_ignore=True)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000432 # Revive files that were deleted in scm.SVN.Revert().
433 self._check_call_svn(['update', '--force'] + flags)
maruel@chromium.org51919772011-06-12 01:27:42 +0000434 return self._get_revision()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000435
maruel@chromium.org51919772011-06-12 01:27:42 +0000436 def _get_revision(self):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000437 out = self._check_output_svn(['info', '.'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000438 revision = int(self._parse_svn_info(out, 'revision'))
439 if revision != self._last_seen_revision:
440 logging.info('Updated to revision %d' % revision)
441 self._last_seen_revision = revision
442 return revision
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000443
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000444 def revisions(self, rev1, rev2):
445 """Returns the number of actual commits, not just the difference between
446 numbers.
447 """
448 rev2 = rev2 or 'HEAD'
449 # Revision range is inclusive and ordering doesn't matter, they'll appear in
450 # the order specified.
451 try:
452 out = self._check_output_svn(
453 ['log', '-q', self.svn_url, '-r', '%s:%s' % (rev1, rev2)])
454 except subprocess.CalledProcessError:
455 return None
456 # Ignore the '----' lines.
457 return len([l for l in out.splitlines() if l.startswith('r')]) - 1
458
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000459
460class GitCheckoutBase(CheckoutBase):
461 """Base class for git checkout. Not to be used as-is."""
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000462 def __init__(self, root_dir, project_name, remote_branch,
463 post_processors=None):
464 super(GitCheckoutBase, self).__init__(
465 root_dir, project_name, post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000466 # There is no reason to not hardcode it.
467 self.remote = 'origin'
468 self.remote_branch = remote_branch
469 self.working_branch = 'working_branch'
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000470
maruel@chromium.org51919772011-06-12 01:27:42 +0000471 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000472 """Resets the git repository in a clean state.
473
474 Checks it out if not present and deletes the working branch.
475 """
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000476 assert self.remote_branch
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000477 assert os.path.isdir(self.project_path)
478 self._check_call_git(['reset', '--hard', '--quiet'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000479 if revision:
480 try:
481 revision = self._check_output_git(['rev-parse', revision])
482 except subprocess.CalledProcessError:
483 self._check_call_git(
484 ['fetch', self.remote, self.remote_branch, '--quiet'])
485 revision = self._check_output_git(['rev-parse', revision])
486 self._check_call_git(['checkout', '--force', '--quiet', revision])
487 else:
488 branches, active = self._branches()
489 if active != 'master':
490 self._check_call_git(['checkout', '--force', '--quiet', 'master'])
491 self._check_call_git(['pull', self.remote, self.remote_branch, '--quiet'])
492 if self.working_branch in branches:
493 self._call_git(['branch', '-D', self.working_branch])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000494
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000495 def apply_patch(self, patches, post_processors=None):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000496 """Applies a patch on 'working_branch' and switch to it.
497
498 Also commits the changes on the local branch.
499
500 Ignores svn properties and raise an exception on unexpected ones.
501 """
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000502 post_processors = post_processors or self.post_processors or []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000503 # It this throws, the checkout is corrupted. Maybe worth deleting it and
504 # trying again?
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000505 if self.remote_branch:
506 self._check_call_git(
507 ['checkout', '-b', self.working_branch,
508 '%s/%s' % (self.remote, self.remote_branch), '--quiet'])
maruel@chromium.org5e975632011-09-29 18:07:06 +0000509 for index, p in enumerate(patches):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000510 try:
511 stdout = ''
512 if p.is_delete:
maruel@chromium.org5e975632011-09-29 18:07:06 +0000513 if (not os.path.exists(p.filename) and
514 any(p1.source_filename == p.filename for p1 in patches[0:index])):
515 # The file could already be deleted if a prior patch with file
516 # rename was already processed. To be sure, look at all the previous
517 # patches to see if they were a file rename.
518 pass
519 else:
520 stdout += self._check_output_git(['rm', p.filename])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000521 else:
522 dirname = os.path.dirname(p.filename)
523 full_dir = os.path.join(self.project_path, dirname)
524 if dirname and not os.path.isdir(full_dir):
525 os.makedirs(full_dir)
526 if p.is_binary:
527 with open(os.path.join(self.project_path, p.filename), 'wb') as f:
528 f.write(p.get())
529 stdout += self._check_output_git(['add', p.filename])
530 else:
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000531 # No need to do anything special with p.is_new or if not
532 # p.diff_hunks. git apply manages all that already.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000533 stdout += self._check_output_git(
maruel@chromium.org5e975632011-09-29 18:07:06 +0000534 ['apply', '--index', '-p%s' % p.patchlevel], stdin=p.get(True))
maruel@chromium.orgd7ca6162012-08-29 17:22:22 +0000535 for name, _ in p.svn_properties:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000536 # Ignore some known auto-props flags through .subversion/config,
537 # bails out on the other ones.
538 # TODO(maruel): Read ~/.subversion/config and detect the rules that
539 # applies here to figure out if the property will be correctly
540 # handled.
maruel@chromium.orgd7ca6162012-08-29 17:22:22 +0000541 if not name in (
maruel@chromium.org9799a072012-01-11 00:26:25 +0000542 'svn:eol-style', 'svn:executable', 'svn:mime-type'):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000543 raise patch.UnsupportedPatchFormat(
544 p.filename,
545 'Cannot apply svn property %s to file %s.' % (
maruel@chromium.orgd7ca6162012-08-29 17:22:22 +0000546 name, p.filename))
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000547 for post in post_processors:
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000548 post(self, p)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000549 except OSError, e:
maruel@chromium.org34f68552012-05-09 19:18:36 +0000550 raise PatchApplicationFailed(p, '%s%s' % (stdout, e))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000551 except subprocess.CalledProcessError, e:
552 raise PatchApplicationFailed(
maruel@chromium.org34f68552012-05-09 19:18:36 +0000553 p, '%s%s' % (stdout, getattr(e, 'stdout', None)))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000554 # Once all the patches are processed and added to the index, commit the
555 # index.
556 self._check_call_git(['commit', '-m', 'Committed patch'])
557 # TODO(maruel): Weirdly enough they don't match, need to investigate.
558 #found_files = self._check_output_git(
559 # ['diff', 'master', '--name-only']).splitlines(False)
560 #assert sorted(patches.filenames) == sorted(found_files), (
561 # sorted(out), sorted(found_files))
562
563 def commit(self, commit_message, user):
564 """Updates the commit message.
565
566 Subclass needs to dcommit or push.
567 """
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000568 assert isinstance(commit_message, unicode)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000569 self._check_call_git(['commit', '--amend', '-m', commit_message])
570 return self._check_output_git(['rev-parse', 'HEAD']).strip()
571
572 def _check_call_git(self, args, **kwargs):
573 kwargs.setdefault('cwd', self.project_path)
574 kwargs.setdefault('stdout', self.VOID)
maruel@chromium.org0bcd1d32011-04-26 15:55:49 +0000575 return subprocess2.check_call_out(['git'] + args, **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000576
577 def _call_git(self, args, **kwargs):
578 """Like check_call but doesn't throw on failure."""
579 kwargs.setdefault('cwd', self.project_path)
580 kwargs.setdefault('stdout', self.VOID)
581 return subprocess2.call(['git'] + args, **kwargs)
582
583 def _check_output_git(self, args, **kwargs):
584 kwargs.setdefault('cwd', self.project_path)
maruel@chromium.org87e6d332011-09-09 19:01:28 +0000585 return subprocess2.check_output(
586 ['git'] + args, stderr=subprocess2.STDOUT, **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000587
588 def _branches(self):
589 """Returns the list of branches and the active one."""
590 out = self._check_output_git(['branch']).splitlines(False)
591 branches = [l[2:] for l in out]
592 active = None
593 for l in out:
594 if l.startswith('*'):
595 active = l[2:]
596 break
597 return branches, active
598
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000599 def revisions(self, rev1, rev2):
600 """Returns the number of actual commits between both hash."""
601 self._fetch_remote()
602
603 rev2 = rev2 or '%s/%s' % (self.remote, self.remote_branch)
604 # Revision range is ]rev1, rev2] and ordering matters.
605 try:
606 out = self._check_output_git(
607 ['log', '--format="%H"' , '%s..%s' % (rev1, rev2)])
608 except subprocess.CalledProcessError:
609 return None
610 return len(out.splitlines())
611
612 def _fetch_remote(self):
613 """Fetches the remote without rebasing."""
614 raise NotImplementedError()
615
616
617class GitCheckout(GitCheckoutBase):
618 """Git checkout implementation."""
619 def _fetch_remote(self):
620 # git fetch is always verbose even with -q -q so redirect its output.
621 self._check_output_git(['fetch', self.remote, self.remote_branch])
622
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000623
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000624class ReadOnlyCheckout(object):
625 """Converts a checkout into a read-only one."""
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000626 def __init__(self, checkout, post_processors=None):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000627 super(ReadOnlyCheckout, self).__init__()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000628 self.checkout = checkout
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000629 self.post_processors = (post_processors or []) + (
630 self.checkout.post_processors or [])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000631
maruel@chromium.org51919772011-06-12 01:27:42 +0000632 def prepare(self, revision):
633 return self.checkout.prepare(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000634
635 def get_settings(self, key):
636 return self.checkout.get_settings(key)
637
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000638 def apply_patch(self, patches, post_processors=None):
639 return self.checkout.apply_patch(
640 patches, post_processors or self.post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000641
642 def commit(self, message, user): # pylint: disable=R0201
643 logging.info('Would have committed for %s with message: %s' % (
644 user, message))
645 return 'FAKE'
646
maruel@chromium.orgbc32ad12012-07-26 13:22:47 +0000647 def revisions(self, rev1, rev2):
648 return self.checkout.revisions(rev1, rev2)
649
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000650 @property
651 def project_name(self):
652 return self.checkout.project_name
653
654 @property
655 def project_path(self):
656 return self.checkout.project_path