blob: 672ad9aeb788799ea655cc7426ce472e906726d9 [file] [log] [blame]
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +00001# coding=utf8
2# Copyright (c) 2011 The Chromium Authors. All rights reserved.
3# 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
10from __future__ import with_statement
11import ConfigParser
12import fnmatch
13import logging
14import os
15import re
16import 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."""
52 def __init__(self, filename, status):
53 super(PatchApplicationFailed, self).__init__(filename, status)
54 self.filename = filename
55 self.status = status
56
57
58class CheckoutBase(object):
59 # Set to None to have verbose output.
60 VOID = subprocess2.VOID
61
maruel@chromium.org6ed8b502011-06-12 01:05:35 +000062 def __init__(self, root_dir, project_name, post_processors):
63 """
64 Args:
65 post_processor: list of lambda(checkout, patches) to call on each of the
66 modified files.
67 """
maruel@chromium.orga5129fb2011-06-20 18:36:25 +000068 super(CheckoutBase, self).__init__()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000069 self.root_dir = root_dir
70 self.project_name = project_name
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +000071 if self.project_name is None:
72 self.project_path = self.root_dir
73 else:
74 self.project_path = os.path.join(self.root_dir, self.project_name)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000075 # Only used for logging purposes.
76 self._last_seen_revision = None
maruel@chromium.orga5129fb2011-06-20 18:36:25 +000077 self.post_processors = post_processors
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000078 assert self.root_dir
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000079 assert self.project_path
80
81 def get_settings(self, key):
82 return get_code_review_setting(self.project_path, key)
83
maruel@chromium.org51919772011-06-12 01:27:42 +000084 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000085 """Checks out a clean copy of the tree and removes any local modification.
86
87 This function shouldn't throw unless the remote repository is inaccessible,
88 there is no free disk space or hard issues like that.
maruel@chromium.org51919772011-06-12 01:27:42 +000089
90 Args:
91 revision: The revision it should sync to, SCM specific.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000092 """
93 raise NotImplementedError()
94
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +000095 def apply_patch(self, patches, post_processors=None):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000096 """Applies a patch and returns the list of modified files.
97
98 This function should throw patch.UnsupportedPatchFormat or
99 PatchApplicationFailed when relevant.
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000100
101 Args:
102 patches: patch.PatchSet object.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000103 """
104 raise NotImplementedError()
105
106 def commit(self, commit_message, user):
107 """Commits the patch upstream, while impersonating 'user'."""
108 raise NotImplementedError()
109
110
111class RawCheckout(CheckoutBase):
112 """Used to apply a patch locally without any intent to commit it.
113
114 To be used by the try server.
115 """
maruel@chromium.org51919772011-06-12 01:27:42 +0000116 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000117 """Stubbed out."""
118 pass
119
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000120 def apply_patch(self, patches, post_processors=None):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000121 """Ignores svn properties."""
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000122 post_processors = post_processors or self.post_processors or []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000123 for p in patches:
124 try:
125 stdout = ''
126 filename = os.path.join(self.project_path, p.filename)
127 if p.is_delete:
128 os.remove(filename)
129 else:
130 dirname = os.path.dirname(p.filename)
131 full_dir = os.path.join(self.project_path, dirname)
132 if dirname and not os.path.isdir(full_dir):
133 os.makedirs(full_dir)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000134
135 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000136 if p.is_binary:
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000137 with open(filepath, 'wb') as f:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000138 f.write(p.get())
139 else:
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000140 if p.diff_hunks:
141 stdout = subprocess2.check_output(
142 ['patch', '-p%s' % p.patchlevel],
143 stdin=p.get(),
maruel@chromium.org87e6d332011-09-09 19:01:28 +0000144 stderr=subprocess2.STDOUT,
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000145 cwd=self.project_path)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000146 elif p.is_new and not os.path.exists(filepath):
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000147 # There is only a header. Just create the file.
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000148 open(filepath, 'w').close()
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000149 for post in post_processors:
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000150 post(self, p)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000151 except OSError, e:
152 raise PatchApplicationFailed(p.filename, '%s%s' % (stdout, e))
153 except subprocess.CalledProcessError, e:
154 raise PatchApplicationFailed(
155 p.filename, '%s%s' % (stdout, getattr(e, 'stdout', None)))
156
157 def commit(self, commit_message, user):
158 """Stubbed out."""
159 raise NotImplementedError('RawCheckout can\'t commit')
160
161
162class SvnConfig(object):
163 """Parses a svn configuration file."""
164 def __init__(self, svn_config_dir=None):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000165 super(SvnConfig, self).__init__()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000166 self.svn_config_dir = svn_config_dir
167 self.default = not bool(self.svn_config_dir)
168 if not self.svn_config_dir:
169 if sys.platform == 'win32':
170 self.svn_config_dir = os.path.join(os.environ['APPDATA'], 'Subversion')
171 else:
172 self.svn_config_dir = os.path.join(os.environ['HOME'], '.subversion')
173 svn_config_file = os.path.join(self.svn_config_dir, 'config')
174 parser = ConfigParser.SafeConfigParser()
175 if os.path.isfile(svn_config_file):
176 parser.read(svn_config_file)
177 else:
178 parser.add_section('auto-props')
179 self.auto_props = dict(parser.items('auto-props'))
180
181
182class SvnMixIn(object):
183 """MixIn class to add svn commands common to both svn and git-svn clients."""
184 # These members need to be set by the subclass.
185 commit_user = None
186 commit_pwd = None
187 svn_url = None
188 project_path = None
189 # Override at class level when necessary. If used, --non-interactive is
190 # implied.
191 svn_config = SvnConfig()
192 # Set to True when non-interactivity is necessary but a custom subversion
193 # configuration directory is not necessary.
194 non_interactive = False
195
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000196 def _add_svn_flags(self, args, non_interactive, credentials=True):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000197 args = ['svn'] + args
198 if not self.svn_config.default:
199 args.extend(['--config-dir', self.svn_config.svn_config_dir])
200 if not self.svn_config.default or self.non_interactive or non_interactive:
201 args.append('--non-interactive')
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000202 if credentials:
203 if self.commit_user:
204 args.extend(['--username', self.commit_user])
205 if self.commit_pwd:
206 args.extend(['--password', self.commit_pwd])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000207 return args
208
209 def _check_call_svn(self, args, **kwargs):
210 """Runs svn and throws an exception if the command failed."""
211 kwargs.setdefault('cwd', self.project_path)
212 kwargs.setdefault('stdout', self.VOID)
maruel@chromium.org0bcd1d32011-04-26 15:55:49 +0000213 return subprocess2.check_call_out(
214 self._add_svn_flags(args, False), **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000215
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000216 def _check_output_svn(self, args, credentials=True, **kwargs):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000217 """Runs svn and throws an exception if the command failed.
218
219 Returns the output.
220 """
221 kwargs.setdefault('cwd', self.project_path)
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000222 return subprocess2.check_output(
maruel@chromium.org87e6d332011-09-09 19:01:28 +0000223 self._add_svn_flags(args, True, credentials),
224 stderr=subprocess2.STDOUT,
225 **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000226
227 @staticmethod
228 def _parse_svn_info(output, key):
229 """Returns value for key from svn info output.
230
231 Case insensitive.
232 """
233 values = {}
234 key = key.lower()
235 for line in output.splitlines(False):
236 if not line:
237 continue
238 k, v = line.split(':', 1)
239 k = k.strip().lower()
240 v = v.strip()
241 assert not k in values
242 values[k] = v
243 return values.get(key, None)
244
245
246class SvnCheckout(CheckoutBase, SvnMixIn):
247 """Manages a subversion checkout."""
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000248 def __init__(self, root_dir, project_name, commit_user, commit_pwd, svn_url,
249 post_processors=None):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000250 CheckoutBase.__init__(self, root_dir, project_name, post_processors)
251 SvnMixIn.__init__(self)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000252 self.commit_user = commit_user
253 self.commit_pwd = commit_pwd
254 self.svn_url = svn_url
255 assert bool(self.commit_user) >= bool(self.commit_pwd)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000256
maruel@chromium.org51919772011-06-12 01:27:42 +0000257 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000258 # Will checkout if the directory is not present.
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000259 assert self.svn_url
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000260 if not os.path.isdir(self.project_path):
261 logging.info('Checking out %s in %s' %
262 (self.project_name, self.project_path))
maruel@chromium.org51919772011-06-12 01:27:42 +0000263 return self._revert(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000264
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000265 def apply_patch(self, patches, post_processors=None):
266 post_processors = post_processors or self.post_processors or []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000267 for p in patches:
268 try:
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000269 # It is important to use credentials=False otherwise credentials could
270 # leak in the error message. Credentials are not necessary here for the
271 # following commands anyway.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000272 stdout = ''
273 if p.is_delete:
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000274 stdout += self._check_output_svn(
275 ['delete', p.filename, '--force'], credentials=False)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000276 else:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000277 # svn add while creating directories otherwise svn add on the
278 # contained files will silently fail.
279 # First, find the root directory that exists.
280 dirname = os.path.dirname(p.filename)
281 dirs_to_create = []
282 while (dirname and
283 not os.path.isdir(os.path.join(self.project_path, dirname))):
284 dirs_to_create.append(dirname)
285 dirname = os.path.dirname(dirname)
286 for dir_to_create in reversed(dirs_to_create):
287 os.mkdir(os.path.join(self.project_path, dir_to_create))
288 stdout += self._check_output_svn(
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000289 ['add', dir_to_create, '--force'], credentials=False)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000290
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000291 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000292 if p.is_binary:
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000293 with open(filepath, 'wb') as f:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000294 f.write(p.get())
295 else:
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000296 if p.diff_hunks:
297 cmd = ['patch', '-p%s' % p.patchlevel, '--forward', '--force']
298 stdout += subprocess2.check_output(
299 cmd, stdin=p.get(), cwd=self.project_path)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000300 elif p.is_new and not os.path.exists(filepath):
301 # There is only a header. Just create the file if it doesn't
302 # exist.
303 open(filepath, 'w').close()
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000304 if p.is_new:
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000305 stdout += self._check_output_svn(
306 ['add', p.filename, '--force'], credentials=False)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000307 for prop in p.svn_properties:
308 stdout += self._check_output_svn(
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000309 ['propset', prop[0], prop[1], p.filename], credentials=False)
310 for prop, values in self.svn_config.auto_props.iteritems():
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000311 if fnmatch.fnmatch(p.filename, prop):
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000312 for value in values.split(';'):
313 if '=' not in value:
314 params = [value, '*']
315 else:
316 params = value.split('=', 1)
317 stdout += self._check_output_svn(
318 ['propset'] + params + [p.filename], credentials=False)
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000319 for post in post_processors:
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000320 post(self, p)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000321 except OSError, e:
322 raise PatchApplicationFailed(p.filename, '%s%s' % (stdout, e))
323 except subprocess.CalledProcessError, e:
324 raise PatchApplicationFailed(
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000325 p.filename,
326 'While running %s;\n%s%s' % (
327 ' '.join(e.cmd), stdout, getattr(e, 'stdout', '')))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000328
329 def commit(self, commit_message, user):
330 logging.info('Committing patch for %s' % user)
331 assert self.commit_user
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000332 assert isinstance(commit_message, unicode)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000333 handle, commit_filename = tempfile.mkstemp(text=True)
334 try:
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000335 # Shouldn't assume default encoding is UTF-8. But really, if you are using
336 # anything else, you are living in another world.
337 os.write(handle, commit_message.encode('utf-8'))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000338 os.close(handle)
339 # When committing, svn won't update the Revision metadata of the checkout,
340 # so if svn commit returns "Committed revision 3.", svn info will still
341 # return "Revision: 2". Since running svn update right after svn commit
342 # creates a race condition with other committers, this code _must_ parse
343 # the output of svn commit and use a regexp to grab the revision number.
344 # Note that "Committed revision N." is localized but subprocess2 forces
345 # LANGUAGE=en.
346 args = ['commit', '--file', commit_filename]
347 # realauthor is parsed by a server-side hook.
348 if user and user != self.commit_user:
349 args.extend(['--with-revprop', 'realauthor=%s' % user])
350 out = self._check_output_svn(args)
351 finally:
352 os.remove(commit_filename)
353 lines = filter(None, out.splitlines())
354 match = re.match(r'^Committed revision (\d+).$', lines[-1])
355 if not match:
356 raise PatchApplicationFailed(
357 None,
358 'Couldn\'t make sense out of svn commit message:\n' + out)
359 return int(match.group(1))
360
maruel@chromium.org51919772011-06-12 01:27:42 +0000361 def _revert(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000362 """Reverts local modifications or checks out if the directory is not
363 present. Use depot_tools's functionality to do this.
364 """
365 flags = ['--ignore-externals']
maruel@chromium.org51919772011-06-12 01:27:42 +0000366 if revision:
367 flags.extend(['--revision', str(revision)])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000368 if not os.path.isdir(self.project_path):
369 logging.info(
370 'Directory %s is not present, checking it out.' % self.project_path)
371 self._check_call_svn(
372 ['checkout', self.svn_url, self.project_path] + flags, cwd=None)
373 else:
374 scm.SVN.Revert(self.project_path)
375 # Revive files that were deleted in scm.SVN.Revert().
376 self._check_call_svn(['update', '--force'] + flags)
maruel@chromium.org51919772011-06-12 01:27:42 +0000377 return self._get_revision()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000378
maruel@chromium.org51919772011-06-12 01:27:42 +0000379 def _get_revision(self):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000380 out = self._check_output_svn(['info', '.'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000381 revision = int(self._parse_svn_info(out, 'revision'))
382 if revision != self._last_seen_revision:
383 logging.info('Updated to revision %d' % revision)
384 self._last_seen_revision = revision
385 return revision
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000386
387
388class GitCheckoutBase(CheckoutBase):
389 """Base class for git checkout. Not to be used as-is."""
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000390 def __init__(self, root_dir, project_name, remote_branch,
391 post_processors=None):
392 super(GitCheckoutBase, self).__init__(
393 root_dir, project_name, post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000394 # There is no reason to not hardcode it.
395 self.remote = 'origin'
396 self.remote_branch = remote_branch
397 self.working_branch = 'working_branch'
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000398
maruel@chromium.org51919772011-06-12 01:27:42 +0000399 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000400 """Resets the git repository in a clean state.
401
402 Checks it out if not present and deletes the working branch.
403 """
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000404 assert self.remote_branch
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000405 assert os.path.isdir(self.project_path)
406 self._check_call_git(['reset', '--hard', '--quiet'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000407 if revision:
408 try:
409 revision = self._check_output_git(['rev-parse', revision])
410 except subprocess.CalledProcessError:
411 self._check_call_git(
412 ['fetch', self.remote, self.remote_branch, '--quiet'])
413 revision = self._check_output_git(['rev-parse', revision])
414 self._check_call_git(['checkout', '--force', '--quiet', revision])
415 else:
416 branches, active = self._branches()
417 if active != 'master':
418 self._check_call_git(['checkout', '--force', '--quiet', 'master'])
419 self._check_call_git(['pull', self.remote, self.remote_branch, '--quiet'])
420 if self.working_branch in branches:
421 self._call_git(['branch', '-D', self.working_branch])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000422
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000423 def apply_patch(self, patches, post_processors=None):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000424 """Applies a patch on 'working_branch' and switch to it.
425
426 Also commits the changes on the local branch.
427
428 Ignores svn properties and raise an exception on unexpected ones.
429 """
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000430 post_processors = post_processors or self.post_processors or []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000431 # It this throws, the checkout is corrupted. Maybe worth deleting it and
432 # trying again?
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000433 if self.remote_branch:
434 self._check_call_git(
435 ['checkout', '-b', self.working_branch,
436 '%s/%s' % (self.remote, self.remote_branch), '--quiet'])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000437 for p in patches:
438 try:
439 stdout = ''
440 if p.is_delete:
441 stdout += self._check_output_git(['rm', p.filename])
442 else:
443 dirname = os.path.dirname(p.filename)
444 full_dir = os.path.join(self.project_path, dirname)
445 if dirname and not os.path.isdir(full_dir):
446 os.makedirs(full_dir)
447 if p.is_binary:
448 with open(os.path.join(self.project_path, p.filename), 'wb') as f:
449 f.write(p.get())
450 stdout += self._check_output_git(['add', p.filename])
451 else:
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000452 # No need to do anything special with p.is_new or if not
453 # p.diff_hunks. git apply manages all that already.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000454 stdout += self._check_output_git(
455 ['apply', '--index', '-p%s' % p.patchlevel], stdin=p.get())
456 for prop in p.svn_properties:
457 # Ignore some known auto-props flags through .subversion/config,
458 # bails out on the other ones.
459 # TODO(maruel): Read ~/.subversion/config and detect the rules that
460 # applies here to figure out if the property will be correctly
461 # handled.
462 if not prop[0] in ('svn:eol-style', 'svn:executable'):
463 raise patch.UnsupportedPatchFormat(
464 p.filename,
465 'Cannot apply svn property %s to file %s.' % (
466 prop[0], p.filename))
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000467 for post in post_processors:
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000468 post(self, p)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000469 except OSError, e:
470 raise PatchApplicationFailed(p.filename, '%s%s' % (stdout, e))
471 except subprocess.CalledProcessError, e:
472 raise PatchApplicationFailed(
473 p.filename, '%s%s' % (stdout, getattr(e, 'stdout', None)))
474 # Once all the patches are processed and added to the index, commit the
475 # index.
476 self._check_call_git(['commit', '-m', 'Committed patch'])
477 # TODO(maruel): Weirdly enough they don't match, need to investigate.
478 #found_files = self._check_output_git(
479 # ['diff', 'master', '--name-only']).splitlines(False)
480 #assert sorted(patches.filenames) == sorted(found_files), (
481 # sorted(out), sorted(found_files))
482
483 def commit(self, commit_message, user):
484 """Updates the commit message.
485
486 Subclass needs to dcommit or push.
487 """
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000488 assert isinstance(commit_message, unicode)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000489 self._check_call_git(['commit', '--amend', '-m', commit_message])
490 return self._check_output_git(['rev-parse', 'HEAD']).strip()
491
492 def _check_call_git(self, args, **kwargs):
493 kwargs.setdefault('cwd', self.project_path)
494 kwargs.setdefault('stdout', self.VOID)
maruel@chromium.org0bcd1d32011-04-26 15:55:49 +0000495 return subprocess2.check_call_out(['git'] + args, **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000496
497 def _call_git(self, args, **kwargs):
498 """Like check_call but doesn't throw on failure."""
499 kwargs.setdefault('cwd', self.project_path)
500 kwargs.setdefault('stdout', self.VOID)
501 return subprocess2.call(['git'] + args, **kwargs)
502
503 def _check_output_git(self, args, **kwargs):
504 kwargs.setdefault('cwd', self.project_path)
maruel@chromium.org87e6d332011-09-09 19:01:28 +0000505 return subprocess2.check_output(
506 ['git'] + args, stderr=subprocess2.STDOUT, **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000507
508 def _branches(self):
509 """Returns the list of branches and the active one."""
510 out = self._check_output_git(['branch']).splitlines(False)
511 branches = [l[2:] for l in out]
512 active = None
513 for l in out:
514 if l.startswith('*'):
515 active = l[2:]
516 break
517 return branches, active
518
519
520class GitSvnCheckoutBase(GitCheckoutBase, SvnMixIn):
521 """Base class for git-svn checkout. Not to be used as-is."""
522 def __init__(self,
523 root_dir, project_name, remote_branch,
524 commit_user, commit_pwd,
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000525 svn_url, trunk, post_processors=None):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000526 """trunk is optional."""
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000527 GitCheckoutBase.__init__(
528 self, root_dir, project_name + '.git', remote_branch, post_processors)
529 SvnMixIn.__init__(self)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000530 self.commit_user = commit_user
531 self.commit_pwd = commit_pwd
532 # svn_url in this case is the root of the svn repository.
533 self.svn_url = svn_url
534 self.trunk = trunk
535 assert bool(self.commit_user) >= bool(self.commit_pwd)
536 assert self.svn_url
537 assert self.trunk
538 self._cache_svn_auth()
539
maruel@chromium.org51919772011-06-12 01:27:42 +0000540 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000541 """Resets the git repository in a clean state."""
542 self._check_call_git(['reset', '--hard', '--quiet'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000543 if revision:
544 try:
545 revision = self._check_output_git(
546 ['svn', 'find-rev', 'r%d' % revision])
547 except subprocess.CalledProcessError:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000548 self._check_call_git(
maruel@chromium.org51919772011-06-12 01:27:42 +0000549 ['fetch', self.remote, self.remote_branch, '--quiet'])
550 revision = self._check_output_git(
551 ['svn', 'find-rev', 'r%d' % revision])
552 super(GitSvnCheckoutBase, self).prepare(revision)
553 else:
554 branches, active = self._branches()
555 if active != 'master':
556 if not 'master' in branches:
557 self._check_call_git(
558 ['checkout', '--quiet', '-b', 'master',
559 '%s/%s' % (self.remote, self.remote_branch)])
560 else:
561 self._check_call_git(['checkout', 'master', '--force', '--quiet'])
562 # git svn rebase --quiet --quiet doesn't work, use two steps to silence
563 # it.
564 self._check_call_git_svn(['fetch', '--quiet', '--quiet'])
565 self._check_call_git(
566 ['rebase', '--quiet', '--quiet',
567 '%s/%s' % (self.remote, self.remote_branch)])
568 if self.working_branch in branches:
569 self._call_git(['branch', '-D', self.working_branch])
570 return self._get_revision()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000571
572 def _git_svn_info(self, key):
573 """Calls git svn info. This doesn't support nor need --config-dir."""
574 return self._parse_svn_info(self._check_output_git(['svn', 'info']), key)
575
576 def commit(self, commit_message, user):
577 """Commits a patch."""
578 logging.info('Committing patch for %s' % user)
579 # Fix the commit message and author. It returns the git hash, which we
580 # ignore unless it's None.
581 if not super(GitSvnCheckoutBase, self).commit(commit_message, user):
582 return None
583 # TODO(maruel): git-svn ignores --config-dir as of git-svn version 1.7.4 and
584 # doesn't support --with-revprop.
585 # Either learn perl and upstream or suck it.
586 kwargs = {}
587 if self.commit_pwd:
588 kwargs['stdin'] = self.commit_pwd + '\n'
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000589 kwargs['stderr'] = subprocess2.STDOUT
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000590 self._check_call_git_svn(
591 ['dcommit', '--rmdir', '--find-copies-harder',
592 '--username', self.commit_user],
593 **kwargs)
594 revision = int(self._git_svn_info('revision'))
595 return revision
596
597 def _cache_svn_auth(self):
598 """Caches the svn credentials. It is necessary since git-svn doesn't prompt
599 for it."""
600 if not self.commit_user or not self.commit_pwd:
601 return
602 # Use capture to lower noise in logs.
603 self._check_output_svn(['ls', self.svn_url], cwd=None)
604
605 def _check_call_git_svn(self, args, **kwargs):
606 """Handles svn authentication while calling git svn."""
607 args = ['svn'] + args
608 if not self.svn_config.default:
609 args.extend(['--config-dir', self.svn_config.svn_config_dir])
610 return self._check_call_git(args, **kwargs)
611
612 def _get_revision(self):
613 revision = int(self._git_svn_info('revision'))
614 if revision != self._last_seen_revision:
maruel@chromium.org51919772011-06-12 01:27:42 +0000615 logging.info('Updated to revision %d' % revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000616 self._last_seen_revision = revision
617 return revision
618
619
620class GitSvnPremadeCheckout(GitSvnCheckoutBase):
621 """Manages a git-svn clone made out from an initial git-svn seed.
622
623 This class is very similar to GitSvnCheckout but is faster to bootstrap
624 because it starts right off with an existing git-svn clone.
625 """
626 def __init__(self,
627 root_dir, project_name, remote_branch,
628 commit_user, commit_pwd,
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000629 svn_url, trunk, git_url, post_processors=None):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000630 super(GitSvnPremadeCheckout, self).__init__(
631 root_dir, project_name, remote_branch,
632 commit_user, commit_pwd,
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000633 svn_url, trunk, post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000634 self.git_url = git_url
635 assert self.git_url
636
maruel@chromium.org51919772011-06-12 01:27:42 +0000637 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000638 """Creates the initial checkout for the repo."""
639 if not os.path.isdir(self.project_path):
640 logging.info('Checking out %s in %s' %
641 (self.project_name, self.project_path))
642 assert self.remote == 'origin'
643 # self.project_path doesn't exist yet.
644 self._check_call_git(
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000645 ['clone', self.git_url, self.project_name, '--quiet'],
646 cwd=self.root_dir,
647 stderr=subprocess2.STDOUT)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000648 try:
649 configured_svn_url = self._check_output_git(
650 ['config', 'svn-remote.svn.url']).strip()
651 except subprocess.CalledProcessError:
652 configured_svn_url = ''
653
654 if configured_svn_url.strip() != self.svn_url:
655 self._check_call_git_svn(
656 ['init',
657 '--prefix', self.remote + '/',
658 '-T', self.trunk,
659 self.svn_url])
660 self._check_call_git_svn(['fetch'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000661 return super(GitSvnPremadeCheckout, self).prepare(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000662
663
664class GitSvnCheckout(GitSvnCheckoutBase):
665 """Manages a git-svn clone.
666
667 Using git-svn hides some of the complexity of using a svn checkout.
668 """
669 def __init__(self,
670 root_dir, project_name,
671 commit_user, commit_pwd,
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000672 svn_url, trunk, post_processors=None):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000673 super(GitSvnCheckout, self).__init__(
674 root_dir, project_name, 'trunk',
675 commit_user, commit_pwd,
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000676 svn_url, trunk, post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000677
maruel@chromium.org51919772011-06-12 01:27:42 +0000678 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000679 """Creates the initial checkout for the repo."""
maruel@chromium.org51919772011-06-12 01:27:42 +0000680 assert not revision, 'Implement revision if necessary'
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000681 if not os.path.isdir(self.project_path):
682 logging.info('Checking out %s in %s' %
683 (self.project_name, self.project_path))
684 # TODO: Create a shallow clone.
685 # self.project_path doesn't exist yet.
686 self._check_call_git_svn(
687 ['clone',
688 '--prefix', self.remote + '/',
689 '-T', self.trunk,
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000690 self.svn_url, self.project_path,
691 '--quiet'],
692 cwd=self.root_dir,
693 stderr=subprocess2.STDOUT)
maruel@chromium.org51919772011-06-12 01:27:42 +0000694 return super(GitSvnCheckout, self).prepare(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000695
696
697class ReadOnlyCheckout(object):
698 """Converts a checkout into a read-only one."""
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000699 def __init__(self, checkout, post_processors=None):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000700 super(ReadOnlyCheckout, self).__init__()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000701 self.checkout = checkout
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000702 self.post_processors = (post_processors or []) + (
703 self.checkout.post_processors or [])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000704
maruel@chromium.org51919772011-06-12 01:27:42 +0000705 def prepare(self, revision):
706 return self.checkout.prepare(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000707
708 def get_settings(self, key):
709 return self.checkout.get_settings(key)
710
maruel@chromium.orgb1d1a782011-09-29 14:13:55 +0000711 def apply_patch(self, patches, post_processors=None):
712 return self.checkout.apply_patch(
713 patches, post_processors or self.post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000714
715 def commit(self, message, user): # pylint: disable=R0201
716 logging.info('Would have committed for %s with message: %s' % (
717 user, message))
718 return 'FAKE'
719
720 @property
721 def project_name(self):
722 return self.checkout.project_name
723
724 @property
725 def project_path(self):
726 return self.checkout.project_path