blob: 96289b3bb362ec083295bbb1d0428e1bbe439d77 [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()
45 except OSError:
46 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.org6ed8b502011-06-12 01:05:35 +000095 def apply_patch(self, patches):
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.org6ed8b502011-06-12 01:05:35 +0000120 def apply_patch(self, patches):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000121 """Ignores svn properties."""
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000122 for p in patches:
123 try:
124 stdout = ''
125 filename = os.path.join(self.project_path, p.filename)
126 if p.is_delete:
127 os.remove(filename)
128 else:
129 dirname = os.path.dirname(p.filename)
130 full_dir = os.path.join(self.project_path, dirname)
131 if dirname and not os.path.isdir(full_dir):
132 os.makedirs(full_dir)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000133
134 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000135 if p.is_binary:
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000136 with open(filepath, 'wb') as f:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000137 f.write(p.get())
138 else:
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000139 if p.diff_hunks:
140 stdout = subprocess2.check_output(
141 ['patch', '-p%s' % p.patchlevel],
142 stdin=p.get(),
143 cwd=self.project_path)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000144 elif p.is_new and not os.path.exists(filepath):
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000145 # There is only a header. Just create the file.
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000146 open(filepath, 'w').close()
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000147 for post in (self.post_processors or []):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000148 post(self, p)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000149 except OSError, e:
150 raise PatchApplicationFailed(p.filename, '%s%s' % (stdout, e))
151 except subprocess.CalledProcessError, e:
152 raise PatchApplicationFailed(
153 p.filename, '%s%s' % (stdout, getattr(e, 'stdout', None)))
154
155 def commit(self, commit_message, user):
156 """Stubbed out."""
157 raise NotImplementedError('RawCheckout can\'t commit')
158
159
160class SvnConfig(object):
161 """Parses a svn configuration file."""
162 def __init__(self, svn_config_dir=None):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000163 super(SvnConfig, self).__init__()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000164 self.svn_config_dir = svn_config_dir
165 self.default = not bool(self.svn_config_dir)
166 if not self.svn_config_dir:
167 if sys.platform == 'win32':
168 self.svn_config_dir = os.path.join(os.environ['APPDATA'], 'Subversion')
169 else:
170 self.svn_config_dir = os.path.join(os.environ['HOME'], '.subversion')
171 svn_config_file = os.path.join(self.svn_config_dir, 'config')
172 parser = ConfigParser.SafeConfigParser()
173 if os.path.isfile(svn_config_file):
174 parser.read(svn_config_file)
175 else:
176 parser.add_section('auto-props')
177 self.auto_props = dict(parser.items('auto-props'))
178
179
180class SvnMixIn(object):
181 """MixIn class to add svn commands common to both svn and git-svn clients."""
182 # These members need to be set by the subclass.
183 commit_user = None
184 commit_pwd = None
185 svn_url = None
186 project_path = None
187 # Override at class level when necessary. If used, --non-interactive is
188 # implied.
189 svn_config = SvnConfig()
190 # Set to True when non-interactivity is necessary but a custom subversion
191 # configuration directory is not necessary.
192 non_interactive = False
193
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000194 def _add_svn_flags(self, args, non_interactive, credentials=True):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000195 args = ['svn'] + args
196 if not self.svn_config.default:
197 args.extend(['--config-dir', self.svn_config.svn_config_dir])
198 if not self.svn_config.default or self.non_interactive or non_interactive:
199 args.append('--non-interactive')
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000200 if credentials:
201 if self.commit_user:
202 args.extend(['--username', self.commit_user])
203 if self.commit_pwd:
204 args.extend(['--password', self.commit_pwd])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000205 return args
206
207 def _check_call_svn(self, args, **kwargs):
208 """Runs svn and throws an exception if the command failed."""
209 kwargs.setdefault('cwd', self.project_path)
210 kwargs.setdefault('stdout', self.VOID)
maruel@chromium.org0bcd1d32011-04-26 15:55:49 +0000211 return subprocess2.check_call_out(
212 self._add_svn_flags(args, False), **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000213
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000214 def _check_output_svn(self, args, credentials=True, **kwargs):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000215 """Runs svn and throws an exception if the command failed.
216
217 Returns the output.
218 """
219 kwargs.setdefault('cwd', self.project_path)
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000220 return subprocess2.check_output(
221 self._add_svn_flags(args, True, credentials), **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000222
223 @staticmethod
224 def _parse_svn_info(output, key):
225 """Returns value for key from svn info output.
226
227 Case insensitive.
228 """
229 values = {}
230 key = key.lower()
231 for line in output.splitlines(False):
232 if not line:
233 continue
234 k, v = line.split(':', 1)
235 k = k.strip().lower()
236 v = v.strip()
237 assert not k in values
238 values[k] = v
239 return values.get(key, None)
240
241
242class SvnCheckout(CheckoutBase, SvnMixIn):
243 """Manages a subversion checkout."""
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000244 def __init__(self, root_dir, project_name, commit_user, commit_pwd, svn_url,
245 post_processors=None):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000246 CheckoutBase.__init__(self, root_dir, project_name, post_processors)
247 SvnMixIn.__init__(self)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000248 self.commit_user = commit_user
249 self.commit_pwd = commit_pwd
250 self.svn_url = svn_url
251 assert bool(self.commit_user) >= bool(self.commit_pwd)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000252
maruel@chromium.org51919772011-06-12 01:27:42 +0000253 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000254 # Will checkout if the directory is not present.
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000255 assert self.svn_url
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000256 if not os.path.isdir(self.project_path):
257 logging.info('Checking out %s in %s' %
258 (self.project_name, self.project_path))
maruel@chromium.org51919772011-06-12 01:27:42 +0000259 return self._revert(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000260
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000261 def apply_patch(self, patches):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000262 for p in patches:
263 try:
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000264 # It is important to use credentials=False otherwise credentials could
265 # leak in the error message. Credentials are not necessary here for the
266 # following commands anyway.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000267 stdout = ''
268 if p.is_delete:
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000269 stdout += self._check_output_svn(
270 ['delete', p.filename, '--force'], credentials=False)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000271 else:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000272 # svn add while creating directories otherwise svn add on the
273 # contained files will silently fail.
274 # First, find the root directory that exists.
275 dirname = os.path.dirname(p.filename)
276 dirs_to_create = []
277 while (dirname and
278 not os.path.isdir(os.path.join(self.project_path, dirname))):
279 dirs_to_create.append(dirname)
280 dirname = os.path.dirname(dirname)
281 for dir_to_create in reversed(dirs_to_create):
282 os.mkdir(os.path.join(self.project_path, dir_to_create))
283 stdout += self._check_output_svn(
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000284 ['add', dir_to_create, '--force'], credentials=False)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000285
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000286 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000287 if p.is_binary:
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000288 with open(filepath, 'wb') as f:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000289 f.write(p.get())
290 else:
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000291 if p.diff_hunks:
292 cmd = ['patch', '-p%s' % p.patchlevel, '--forward', '--force']
293 stdout += subprocess2.check_output(
294 cmd, stdin=p.get(), cwd=self.project_path)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000295 elif p.is_new and not os.path.exists(filepath):
296 # There is only a header. Just create the file if it doesn't
297 # exist.
298 open(filepath, 'w').close()
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000299 if p.is_new:
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000300 stdout += self._check_output_svn(
301 ['add', p.filename, '--force'], credentials=False)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000302 for prop in p.svn_properties:
303 stdout += self._check_output_svn(
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000304 ['propset', prop[0], prop[1], p.filename], credentials=False)
305 for prop, values in self.svn_config.auto_props.iteritems():
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000306 if fnmatch.fnmatch(p.filename, prop):
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000307 for value in values.split(';'):
308 if '=' not in value:
309 params = [value, '*']
310 else:
311 params = value.split('=', 1)
312 stdout += self._check_output_svn(
313 ['propset'] + params + [p.filename], credentials=False)
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000314 for post in (self.post_processors or []):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000315 post(self, p)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000316 except OSError, e:
317 raise PatchApplicationFailed(p.filename, '%s%s' % (stdout, e))
318 except subprocess.CalledProcessError, e:
319 raise PatchApplicationFailed(
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000320 p.filename,
321 'While running %s;\n%s%s' % (
322 ' '.join(e.cmd), stdout, getattr(e, 'stdout', '')))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000323
324 def commit(self, commit_message, user):
325 logging.info('Committing patch for %s' % user)
326 assert self.commit_user
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000327 assert isinstance(commit_message, unicode)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000328 handle, commit_filename = tempfile.mkstemp(text=True)
329 try:
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000330 # Shouldn't assume default encoding is UTF-8. But really, if you are using
331 # anything else, you are living in another world.
332 os.write(handle, commit_message.encode('utf-8'))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000333 os.close(handle)
334 # When committing, svn won't update the Revision metadata of the checkout,
335 # so if svn commit returns "Committed revision 3.", svn info will still
336 # return "Revision: 2". Since running svn update right after svn commit
337 # creates a race condition with other committers, this code _must_ parse
338 # the output of svn commit and use a regexp to grab the revision number.
339 # Note that "Committed revision N." is localized but subprocess2 forces
340 # LANGUAGE=en.
341 args = ['commit', '--file', commit_filename]
342 # realauthor is parsed by a server-side hook.
343 if user and user != self.commit_user:
344 args.extend(['--with-revprop', 'realauthor=%s' % user])
345 out = self._check_output_svn(args)
346 finally:
347 os.remove(commit_filename)
348 lines = filter(None, out.splitlines())
349 match = re.match(r'^Committed revision (\d+).$', lines[-1])
350 if not match:
351 raise PatchApplicationFailed(
352 None,
353 'Couldn\'t make sense out of svn commit message:\n' + out)
354 return int(match.group(1))
355
maruel@chromium.org51919772011-06-12 01:27:42 +0000356 def _revert(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000357 """Reverts local modifications or checks out if the directory is not
358 present. Use depot_tools's functionality to do this.
359 """
360 flags = ['--ignore-externals']
maruel@chromium.org51919772011-06-12 01:27:42 +0000361 if revision:
362 flags.extend(['--revision', str(revision)])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000363 if not os.path.isdir(self.project_path):
364 logging.info(
365 'Directory %s is not present, checking it out.' % self.project_path)
366 self._check_call_svn(
367 ['checkout', self.svn_url, self.project_path] + flags, cwd=None)
368 else:
369 scm.SVN.Revert(self.project_path)
370 # Revive files that were deleted in scm.SVN.Revert().
371 self._check_call_svn(['update', '--force'] + flags)
maruel@chromium.org51919772011-06-12 01:27:42 +0000372 return self._get_revision()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000373
maruel@chromium.org51919772011-06-12 01:27:42 +0000374 def _get_revision(self):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000375 out = self._check_output_svn(['info', '.'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000376 revision = int(self._parse_svn_info(out, 'revision'))
377 if revision != self._last_seen_revision:
378 logging.info('Updated to revision %d' % revision)
379 self._last_seen_revision = revision
380 return revision
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000381
382
383class GitCheckoutBase(CheckoutBase):
384 """Base class for git checkout. Not to be used as-is."""
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000385 def __init__(self, root_dir, project_name, remote_branch,
386 post_processors=None):
387 super(GitCheckoutBase, self).__init__(
388 root_dir, project_name, post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000389 # There is no reason to not hardcode it.
390 self.remote = 'origin'
391 self.remote_branch = remote_branch
392 self.working_branch = 'working_branch'
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000393
maruel@chromium.org51919772011-06-12 01:27:42 +0000394 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000395 """Resets the git repository in a clean state.
396
397 Checks it out if not present and deletes the working branch.
398 """
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000399 assert self.remote_branch
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000400 assert os.path.isdir(self.project_path)
401 self._check_call_git(['reset', '--hard', '--quiet'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000402 if revision:
403 try:
404 revision = self._check_output_git(['rev-parse', revision])
405 except subprocess.CalledProcessError:
406 self._check_call_git(
407 ['fetch', self.remote, self.remote_branch, '--quiet'])
408 revision = self._check_output_git(['rev-parse', revision])
409 self._check_call_git(['checkout', '--force', '--quiet', revision])
410 else:
411 branches, active = self._branches()
412 if active != 'master':
413 self._check_call_git(['checkout', '--force', '--quiet', 'master'])
414 self._check_call_git(['pull', self.remote, self.remote_branch, '--quiet'])
415 if self.working_branch in branches:
416 self._call_git(['branch', '-D', self.working_branch])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000417
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000418 def apply_patch(self, patches):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000419 """Applies a patch on 'working_branch' and switch to it.
420
421 Also commits the changes on the local branch.
422
423 Ignores svn properties and raise an exception on unexpected ones.
424 """
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000425 # It this throws, the checkout is corrupted. Maybe worth deleting it and
426 # trying again?
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000427 if self.remote_branch:
428 self._check_call_git(
429 ['checkout', '-b', self.working_branch,
430 '%s/%s' % (self.remote, self.remote_branch), '--quiet'])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000431 for p in patches:
432 try:
433 stdout = ''
434 if p.is_delete:
435 stdout += self._check_output_git(['rm', p.filename])
436 else:
437 dirname = os.path.dirname(p.filename)
438 full_dir = os.path.join(self.project_path, dirname)
439 if dirname and not os.path.isdir(full_dir):
440 os.makedirs(full_dir)
441 if p.is_binary:
442 with open(os.path.join(self.project_path, p.filename), 'wb') as f:
443 f.write(p.get())
444 stdout += self._check_output_git(['add', p.filename])
445 else:
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000446 # No need to do anything special with p.is_new or if not
447 # p.diff_hunks. git apply manages all that already.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000448 stdout += self._check_output_git(
449 ['apply', '--index', '-p%s' % p.patchlevel], stdin=p.get())
450 for prop in p.svn_properties:
451 # Ignore some known auto-props flags through .subversion/config,
452 # bails out on the other ones.
453 # TODO(maruel): Read ~/.subversion/config and detect the rules that
454 # applies here to figure out if the property will be correctly
455 # handled.
456 if not prop[0] in ('svn:eol-style', 'svn:executable'):
457 raise patch.UnsupportedPatchFormat(
458 p.filename,
459 'Cannot apply svn property %s to file %s.' % (
460 prop[0], p.filename))
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000461 for post in (self.post_processors or []):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000462 post(self, p)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000463 except OSError, e:
464 raise PatchApplicationFailed(p.filename, '%s%s' % (stdout, e))
465 except subprocess.CalledProcessError, e:
466 raise PatchApplicationFailed(
467 p.filename, '%s%s' % (stdout, getattr(e, 'stdout', None)))
468 # Once all the patches are processed and added to the index, commit the
469 # index.
470 self._check_call_git(['commit', '-m', 'Committed patch'])
471 # TODO(maruel): Weirdly enough they don't match, need to investigate.
472 #found_files = self._check_output_git(
473 # ['diff', 'master', '--name-only']).splitlines(False)
474 #assert sorted(patches.filenames) == sorted(found_files), (
475 # sorted(out), sorted(found_files))
476
477 def commit(self, commit_message, user):
478 """Updates the commit message.
479
480 Subclass needs to dcommit or push.
481 """
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000482 assert isinstance(commit_message, unicode)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000483 self._check_call_git(['commit', '--amend', '-m', commit_message])
484 return self._check_output_git(['rev-parse', 'HEAD']).strip()
485
486 def _check_call_git(self, args, **kwargs):
487 kwargs.setdefault('cwd', self.project_path)
488 kwargs.setdefault('stdout', self.VOID)
maruel@chromium.org0bcd1d32011-04-26 15:55:49 +0000489 return subprocess2.check_call_out(['git'] + args, **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000490
491 def _call_git(self, args, **kwargs):
492 """Like check_call but doesn't throw on failure."""
493 kwargs.setdefault('cwd', self.project_path)
494 kwargs.setdefault('stdout', self.VOID)
495 return subprocess2.call(['git'] + args, **kwargs)
496
497 def _check_output_git(self, args, **kwargs):
498 kwargs.setdefault('cwd', self.project_path)
499 return subprocess2.check_output(['git'] + args, **kwargs)
500
501 def _branches(self):
502 """Returns the list of branches and the active one."""
503 out = self._check_output_git(['branch']).splitlines(False)
504 branches = [l[2:] for l in out]
505 active = None
506 for l in out:
507 if l.startswith('*'):
508 active = l[2:]
509 break
510 return branches, active
511
512
513class GitSvnCheckoutBase(GitCheckoutBase, SvnMixIn):
514 """Base class for git-svn checkout. Not to be used as-is."""
515 def __init__(self,
516 root_dir, project_name, remote_branch,
517 commit_user, commit_pwd,
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000518 svn_url, trunk, post_processors=None):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000519 """trunk is optional."""
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000520 GitCheckoutBase.__init__(
521 self, root_dir, project_name + '.git', remote_branch, post_processors)
522 SvnMixIn.__init__(self)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000523 self.commit_user = commit_user
524 self.commit_pwd = commit_pwd
525 # svn_url in this case is the root of the svn repository.
526 self.svn_url = svn_url
527 self.trunk = trunk
528 assert bool(self.commit_user) >= bool(self.commit_pwd)
529 assert self.svn_url
530 assert self.trunk
531 self._cache_svn_auth()
532
maruel@chromium.org51919772011-06-12 01:27:42 +0000533 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000534 """Resets the git repository in a clean state."""
535 self._check_call_git(['reset', '--hard', '--quiet'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000536 if revision:
537 try:
538 revision = self._check_output_git(
539 ['svn', 'find-rev', 'r%d' % revision])
540 except subprocess.CalledProcessError:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000541 self._check_call_git(
maruel@chromium.org51919772011-06-12 01:27:42 +0000542 ['fetch', self.remote, self.remote_branch, '--quiet'])
543 revision = self._check_output_git(
544 ['svn', 'find-rev', 'r%d' % revision])
545 super(GitSvnCheckoutBase, self).prepare(revision)
546 else:
547 branches, active = self._branches()
548 if active != 'master':
549 if not 'master' in branches:
550 self._check_call_git(
551 ['checkout', '--quiet', '-b', 'master',
552 '%s/%s' % (self.remote, self.remote_branch)])
553 else:
554 self._check_call_git(['checkout', 'master', '--force', '--quiet'])
555 # git svn rebase --quiet --quiet doesn't work, use two steps to silence
556 # it.
557 self._check_call_git_svn(['fetch', '--quiet', '--quiet'])
558 self._check_call_git(
559 ['rebase', '--quiet', '--quiet',
560 '%s/%s' % (self.remote, self.remote_branch)])
561 if self.working_branch in branches:
562 self._call_git(['branch', '-D', self.working_branch])
563 return self._get_revision()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000564
565 def _git_svn_info(self, key):
566 """Calls git svn info. This doesn't support nor need --config-dir."""
567 return self._parse_svn_info(self._check_output_git(['svn', 'info']), key)
568
569 def commit(self, commit_message, user):
570 """Commits a patch."""
571 logging.info('Committing patch for %s' % user)
572 # Fix the commit message and author. It returns the git hash, which we
573 # ignore unless it's None.
574 if not super(GitSvnCheckoutBase, self).commit(commit_message, user):
575 return None
576 # TODO(maruel): git-svn ignores --config-dir as of git-svn version 1.7.4 and
577 # doesn't support --with-revprop.
578 # Either learn perl and upstream or suck it.
579 kwargs = {}
580 if self.commit_pwd:
581 kwargs['stdin'] = self.commit_pwd + '\n'
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000582 kwargs['stderr'] = subprocess2.STDOUT
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000583 self._check_call_git_svn(
584 ['dcommit', '--rmdir', '--find-copies-harder',
585 '--username', self.commit_user],
586 **kwargs)
587 revision = int(self._git_svn_info('revision'))
588 return revision
589
590 def _cache_svn_auth(self):
591 """Caches the svn credentials. It is necessary since git-svn doesn't prompt
592 for it."""
593 if not self.commit_user or not self.commit_pwd:
594 return
595 # Use capture to lower noise in logs.
596 self._check_output_svn(['ls', self.svn_url], cwd=None)
597
598 def _check_call_git_svn(self, args, **kwargs):
599 """Handles svn authentication while calling git svn."""
600 args = ['svn'] + args
601 if not self.svn_config.default:
602 args.extend(['--config-dir', self.svn_config.svn_config_dir])
603 return self._check_call_git(args, **kwargs)
604
605 def _get_revision(self):
606 revision = int(self._git_svn_info('revision'))
607 if revision != self._last_seen_revision:
maruel@chromium.org51919772011-06-12 01:27:42 +0000608 logging.info('Updated to revision %d' % revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000609 self._last_seen_revision = revision
610 return revision
611
612
613class GitSvnPremadeCheckout(GitSvnCheckoutBase):
614 """Manages a git-svn clone made out from an initial git-svn seed.
615
616 This class is very similar to GitSvnCheckout but is faster to bootstrap
617 because it starts right off with an existing git-svn clone.
618 """
619 def __init__(self,
620 root_dir, project_name, remote_branch,
621 commit_user, commit_pwd,
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000622 svn_url, trunk, git_url, post_processors=None):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000623 super(GitSvnPremadeCheckout, self).__init__(
624 root_dir, project_name, remote_branch,
625 commit_user, commit_pwd,
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000626 svn_url, trunk, post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000627 self.git_url = git_url
628 assert self.git_url
629
maruel@chromium.org51919772011-06-12 01:27:42 +0000630 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000631 """Creates the initial checkout for the repo."""
632 if not os.path.isdir(self.project_path):
633 logging.info('Checking out %s in %s' %
634 (self.project_name, self.project_path))
635 assert self.remote == 'origin'
636 # self.project_path doesn't exist yet.
637 self._check_call_git(
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000638 ['clone', self.git_url, self.project_name, '--quiet'],
639 cwd=self.root_dir,
640 stderr=subprocess2.STDOUT)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000641 try:
642 configured_svn_url = self._check_output_git(
643 ['config', 'svn-remote.svn.url']).strip()
644 except subprocess.CalledProcessError:
645 configured_svn_url = ''
646
647 if configured_svn_url.strip() != self.svn_url:
648 self._check_call_git_svn(
649 ['init',
650 '--prefix', self.remote + '/',
651 '-T', self.trunk,
652 self.svn_url])
653 self._check_call_git_svn(['fetch'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000654 return super(GitSvnPremadeCheckout, self).prepare(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000655
656
657class GitSvnCheckout(GitSvnCheckoutBase):
658 """Manages a git-svn clone.
659
660 Using git-svn hides some of the complexity of using a svn checkout.
661 """
662 def __init__(self,
663 root_dir, project_name,
664 commit_user, commit_pwd,
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000665 svn_url, trunk, post_processors=None):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000666 super(GitSvnCheckout, self).__init__(
667 root_dir, project_name, 'trunk',
668 commit_user, commit_pwd,
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000669 svn_url, trunk, post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000670
maruel@chromium.org51919772011-06-12 01:27:42 +0000671 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000672 """Creates the initial checkout for the repo."""
maruel@chromium.org51919772011-06-12 01:27:42 +0000673 assert not revision, 'Implement revision if necessary'
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000674 if not os.path.isdir(self.project_path):
675 logging.info('Checking out %s in %s' %
676 (self.project_name, self.project_path))
677 # TODO: Create a shallow clone.
678 # self.project_path doesn't exist yet.
679 self._check_call_git_svn(
680 ['clone',
681 '--prefix', self.remote + '/',
682 '-T', self.trunk,
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000683 self.svn_url, self.project_path,
684 '--quiet'],
685 cwd=self.root_dir,
686 stderr=subprocess2.STDOUT)
maruel@chromium.org51919772011-06-12 01:27:42 +0000687 return super(GitSvnCheckout, self).prepare(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000688
689
690class ReadOnlyCheckout(object):
691 """Converts a checkout into a read-only one."""
692 def __init__(self, checkout):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000693 super(ReadOnlyCheckout, self).__init__()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000694 self.checkout = checkout
695
maruel@chromium.org51919772011-06-12 01:27:42 +0000696 def prepare(self, revision):
697 return self.checkout.prepare(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000698
699 def get_settings(self, key):
700 return self.checkout.get_settings(key)
701
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000702 def apply_patch(self, patches):
703 return self.checkout.apply_patch(patches)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000704
705 def commit(self, message, user): # pylint: disable=R0201
706 logging.info('Would have committed for %s with message: %s' % (
707 user, message))
708 return 'FAKE'
709
710 @property
711 def project_name(self):
712 return self.checkout.project_name
713
714 @property
715 def project_path(self):
716 return self.checkout.project_path