blob: 5f61d6f16598e0533e4fb37983c9551a6d9f377d [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.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(),
maruel@chromium.org87e6d332011-09-09 19:01:28 +0000143 stderr=subprocess2.STDOUT,
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000144 cwd=self.project_path)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000145 elif p.is_new and not os.path.exists(filepath):
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000146 # There is only a header. Just create the file.
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000147 open(filepath, 'w').close()
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000148 for post in (self.post_processors or []):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000149 post(self, p)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000150 except OSError, e:
151 raise PatchApplicationFailed(p.filename, '%s%s' % (stdout, e))
152 except subprocess.CalledProcessError, e:
153 raise PatchApplicationFailed(
154 p.filename, '%s%s' % (stdout, getattr(e, 'stdout', None)))
155
156 def commit(self, commit_message, user):
157 """Stubbed out."""
158 raise NotImplementedError('RawCheckout can\'t commit')
159
160
161class SvnConfig(object):
162 """Parses a svn configuration file."""
163 def __init__(self, svn_config_dir=None):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000164 super(SvnConfig, self).__init__()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000165 self.svn_config_dir = svn_config_dir
166 self.default = not bool(self.svn_config_dir)
167 if not self.svn_config_dir:
168 if sys.platform == 'win32':
169 self.svn_config_dir = os.path.join(os.environ['APPDATA'], 'Subversion')
170 else:
171 self.svn_config_dir = os.path.join(os.environ['HOME'], '.subversion')
172 svn_config_file = os.path.join(self.svn_config_dir, 'config')
173 parser = ConfigParser.SafeConfigParser()
174 if os.path.isfile(svn_config_file):
175 parser.read(svn_config_file)
176 else:
177 parser.add_section('auto-props')
178 self.auto_props = dict(parser.items('auto-props'))
179
180
181class SvnMixIn(object):
182 """MixIn class to add svn commands common to both svn and git-svn clients."""
183 # These members need to be set by the subclass.
184 commit_user = None
185 commit_pwd = None
186 svn_url = None
187 project_path = None
188 # Override at class level when necessary. If used, --non-interactive is
189 # implied.
190 svn_config = SvnConfig()
191 # Set to True when non-interactivity is necessary but a custom subversion
192 # configuration directory is not necessary.
193 non_interactive = False
194
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000195 def _add_svn_flags(self, args, non_interactive, credentials=True):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000196 args = ['svn'] + args
197 if not self.svn_config.default:
198 args.extend(['--config-dir', self.svn_config.svn_config_dir])
199 if not self.svn_config.default or self.non_interactive or non_interactive:
200 args.append('--non-interactive')
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000201 if credentials:
202 if self.commit_user:
203 args.extend(['--username', self.commit_user])
204 if self.commit_pwd:
205 args.extend(['--password', self.commit_pwd])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000206 return args
207
208 def _check_call_svn(self, args, **kwargs):
209 """Runs svn and throws an exception if the command failed."""
210 kwargs.setdefault('cwd', self.project_path)
211 kwargs.setdefault('stdout', self.VOID)
maruel@chromium.org0bcd1d32011-04-26 15:55:49 +0000212 return subprocess2.check_call_out(
213 self._add_svn_flags(args, False), **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000214
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000215 def _check_output_svn(self, args, credentials=True, **kwargs):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000216 """Runs svn and throws an exception if the command failed.
217
218 Returns the output.
219 """
220 kwargs.setdefault('cwd', self.project_path)
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000221 return subprocess2.check_output(
maruel@chromium.org87e6d332011-09-09 19:01:28 +0000222 self._add_svn_flags(args, True, credentials),
223 stderr=subprocess2.STDOUT,
224 **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000225
226 @staticmethod
227 def _parse_svn_info(output, key):
228 """Returns value for key from svn info output.
229
230 Case insensitive.
231 """
232 values = {}
233 key = key.lower()
234 for line in output.splitlines(False):
235 if not line:
236 continue
237 k, v = line.split(':', 1)
238 k = k.strip().lower()
239 v = v.strip()
240 assert not k in values
241 values[k] = v
242 return values.get(key, None)
243
244
245class SvnCheckout(CheckoutBase, SvnMixIn):
246 """Manages a subversion checkout."""
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000247 def __init__(self, root_dir, project_name, commit_user, commit_pwd, svn_url,
248 post_processors=None):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000249 CheckoutBase.__init__(self, root_dir, project_name, post_processors)
250 SvnMixIn.__init__(self)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000251 self.commit_user = commit_user
252 self.commit_pwd = commit_pwd
253 self.svn_url = svn_url
254 assert bool(self.commit_user) >= bool(self.commit_pwd)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000255
maruel@chromium.org51919772011-06-12 01:27:42 +0000256 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000257 # Will checkout if the directory is not present.
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000258 assert self.svn_url
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000259 if not os.path.isdir(self.project_path):
260 logging.info('Checking out %s in %s' %
261 (self.project_name, self.project_path))
maruel@chromium.org51919772011-06-12 01:27:42 +0000262 return self._revert(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000263
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000264 def apply_patch(self, patches):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000265 for p in patches:
266 try:
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000267 # It is important to use credentials=False otherwise credentials could
268 # leak in the error message. Credentials are not necessary here for the
269 # following commands anyway.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000270 stdout = ''
271 if p.is_delete:
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000272 stdout += self._check_output_svn(
273 ['delete', p.filename, '--force'], credentials=False)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000274 else:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000275 # svn add while creating directories otherwise svn add on the
276 # contained files will silently fail.
277 # First, find the root directory that exists.
278 dirname = os.path.dirname(p.filename)
279 dirs_to_create = []
280 while (dirname and
281 not os.path.isdir(os.path.join(self.project_path, dirname))):
282 dirs_to_create.append(dirname)
283 dirname = os.path.dirname(dirname)
284 for dir_to_create in reversed(dirs_to_create):
285 os.mkdir(os.path.join(self.project_path, dir_to_create))
286 stdout += self._check_output_svn(
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000287 ['add', dir_to_create, '--force'], credentials=False)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000288
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000289 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000290 if p.is_binary:
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000291 with open(filepath, 'wb') as f:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000292 f.write(p.get())
293 else:
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000294 if p.diff_hunks:
295 cmd = ['patch', '-p%s' % p.patchlevel, '--forward', '--force']
296 stdout += subprocess2.check_output(
297 cmd, stdin=p.get(), cwd=self.project_path)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000298 elif p.is_new and not os.path.exists(filepath):
299 # There is only a header. Just create the file if it doesn't
300 # exist.
301 open(filepath, 'w').close()
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000302 if p.is_new:
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000303 stdout += self._check_output_svn(
304 ['add', p.filename, '--force'], credentials=False)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000305 for prop in p.svn_properties:
306 stdout += self._check_output_svn(
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000307 ['propset', prop[0], prop[1], p.filename], credentials=False)
308 for prop, values in self.svn_config.auto_props.iteritems():
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000309 if fnmatch.fnmatch(p.filename, prop):
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000310 for value in values.split(';'):
311 if '=' not in value:
312 params = [value, '*']
313 else:
314 params = value.split('=', 1)
315 stdout += self._check_output_svn(
316 ['propset'] + params + [p.filename], credentials=False)
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000317 for post in (self.post_processors or []):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000318 post(self, p)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000319 except OSError, e:
320 raise PatchApplicationFailed(p.filename, '%s%s' % (stdout, e))
321 except subprocess.CalledProcessError, e:
322 raise PatchApplicationFailed(
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000323 p.filename,
324 'While running %s;\n%s%s' % (
325 ' '.join(e.cmd), stdout, getattr(e, 'stdout', '')))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000326
327 def commit(self, commit_message, user):
328 logging.info('Committing patch for %s' % user)
329 assert self.commit_user
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000330 assert isinstance(commit_message, unicode)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000331 handle, commit_filename = tempfile.mkstemp(text=True)
332 try:
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000333 # Shouldn't assume default encoding is UTF-8. But really, if you are using
334 # anything else, you are living in another world.
335 os.write(handle, commit_message.encode('utf-8'))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000336 os.close(handle)
337 # When committing, svn won't update the Revision metadata of the checkout,
338 # so if svn commit returns "Committed revision 3.", svn info will still
339 # return "Revision: 2". Since running svn update right after svn commit
340 # creates a race condition with other committers, this code _must_ parse
341 # the output of svn commit and use a regexp to grab the revision number.
342 # Note that "Committed revision N." is localized but subprocess2 forces
343 # LANGUAGE=en.
344 args = ['commit', '--file', commit_filename]
345 # realauthor is parsed by a server-side hook.
346 if user and user != self.commit_user:
347 args.extend(['--with-revprop', 'realauthor=%s' % user])
348 out = self._check_output_svn(args)
349 finally:
350 os.remove(commit_filename)
351 lines = filter(None, out.splitlines())
352 match = re.match(r'^Committed revision (\d+).$', lines[-1])
353 if not match:
354 raise PatchApplicationFailed(
355 None,
356 'Couldn\'t make sense out of svn commit message:\n' + out)
357 return int(match.group(1))
358
maruel@chromium.org51919772011-06-12 01:27:42 +0000359 def _revert(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000360 """Reverts local modifications or checks out if the directory is not
361 present. Use depot_tools's functionality to do this.
362 """
363 flags = ['--ignore-externals']
maruel@chromium.org51919772011-06-12 01:27:42 +0000364 if revision:
365 flags.extend(['--revision', str(revision)])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000366 if not os.path.isdir(self.project_path):
367 logging.info(
368 'Directory %s is not present, checking it out.' % self.project_path)
369 self._check_call_svn(
370 ['checkout', self.svn_url, self.project_path] + flags, cwd=None)
371 else:
372 scm.SVN.Revert(self.project_path)
373 # Revive files that were deleted in scm.SVN.Revert().
374 self._check_call_svn(['update', '--force'] + flags)
maruel@chromium.org51919772011-06-12 01:27:42 +0000375 return self._get_revision()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000376
maruel@chromium.org51919772011-06-12 01:27:42 +0000377 def _get_revision(self):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000378 out = self._check_output_svn(['info', '.'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000379 revision = int(self._parse_svn_info(out, 'revision'))
380 if revision != self._last_seen_revision:
381 logging.info('Updated to revision %d' % revision)
382 self._last_seen_revision = revision
383 return revision
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000384
385
386class GitCheckoutBase(CheckoutBase):
387 """Base class for git checkout. Not to be used as-is."""
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000388 def __init__(self, root_dir, project_name, remote_branch,
389 post_processors=None):
390 super(GitCheckoutBase, self).__init__(
391 root_dir, project_name, post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000392 # There is no reason to not hardcode it.
393 self.remote = 'origin'
394 self.remote_branch = remote_branch
395 self.working_branch = 'working_branch'
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000396
maruel@chromium.org51919772011-06-12 01:27:42 +0000397 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000398 """Resets the git repository in a clean state.
399
400 Checks it out if not present and deletes the working branch.
401 """
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000402 assert self.remote_branch
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000403 assert os.path.isdir(self.project_path)
404 self._check_call_git(['reset', '--hard', '--quiet'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000405 if revision:
406 try:
407 revision = self._check_output_git(['rev-parse', revision])
408 except subprocess.CalledProcessError:
409 self._check_call_git(
410 ['fetch', self.remote, self.remote_branch, '--quiet'])
411 revision = self._check_output_git(['rev-parse', revision])
412 self._check_call_git(['checkout', '--force', '--quiet', revision])
413 else:
414 branches, active = self._branches()
415 if active != 'master':
416 self._check_call_git(['checkout', '--force', '--quiet', 'master'])
417 self._check_call_git(['pull', self.remote, self.remote_branch, '--quiet'])
418 if self.working_branch in branches:
419 self._call_git(['branch', '-D', self.working_branch])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000420
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000421 def apply_patch(self, patches):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000422 """Applies a patch on 'working_branch' and switch to it.
423
424 Also commits the changes on the local branch.
425
426 Ignores svn properties and raise an exception on unexpected ones.
427 """
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000428 # It this throws, the checkout is corrupted. Maybe worth deleting it and
429 # trying again?
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000430 if self.remote_branch:
431 self._check_call_git(
432 ['checkout', '-b', self.working_branch,
433 '%s/%s' % (self.remote, self.remote_branch), '--quiet'])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000434 for p in patches:
435 try:
436 stdout = ''
437 if p.is_delete:
438 stdout += self._check_output_git(['rm', p.filename])
439 else:
440 dirname = os.path.dirname(p.filename)
441 full_dir = os.path.join(self.project_path, dirname)
442 if dirname and not os.path.isdir(full_dir):
443 os.makedirs(full_dir)
444 if p.is_binary:
445 with open(os.path.join(self.project_path, p.filename), 'wb') as f:
446 f.write(p.get())
447 stdout += self._check_output_git(['add', p.filename])
448 else:
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000449 # No need to do anything special with p.is_new or if not
450 # p.diff_hunks. git apply manages all that already.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000451 stdout += self._check_output_git(
452 ['apply', '--index', '-p%s' % p.patchlevel], stdin=p.get())
453 for prop in p.svn_properties:
454 # Ignore some known auto-props flags through .subversion/config,
455 # bails out on the other ones.
456 # TODO(maruel): Read ~/.subversion/config and detect the rules that
457 # applies here to figure out if the property will be correctly
458 # handled.
459 if not prop[0] in ('svn:eol-style', 'svn:executable'):
460 raise patch.UnsupportedPatchFormat(
461 p.filename,
462 'Cannot apply svn property %s to file %s.' % (
463 prop[0], p.filename))
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000464 for post in (self.post_processors or []):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000465 post(self, p)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000466 except OSError, e:
467 raise PatchApplicationFailed(p.filename, '%s%s' % (stdout, e))
468 except subprocess.CalledProcessError, e:
469 raise PatchApplicationFailed(
470 p.filename, '%s%s' % (stdout, getattr(e, 'stdout', None)))
471 # Once all the patches are processed and added to the index, commit the
472 # index.
473 self._check_call_git(['commit', '-m', 'Committed patch'])
474 # TODO(maruel): Weirdly enough they don't match, need to investigate.
475 #found_files = self._check_output_git(
476 # ['diff', 'master', '--name-only']).splitlines(False)
477 #assert sorted(patches.filenames) == sorted(found_files), (
478 # sorted(out), sorted(found_files))
479
480 def commit(self, commit_message, user):
481 """Updates the commit message.
482
483 Subclass needs to dcommit or push.
484 """
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000485 assert isinstance(commit_message, unicode)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000486 self._check_call_git(['commit', '--amend', '-m', commit_message])
487 return self._check_output_git(['rev-parse', 'HEAD']).strip()
488
489 def _check_call_git(self, args, **kwargs):
490 kwargs.setdefault('cwd', self.project_path)
491 kwargs.setdefault('stdout', self.VOID)
maruel@chromium.org0bcd1d32011-04-26 15:55:49 +0000492 return subprocess2.check_call_out(['git'] + args, **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000493
494 def _call_git(self, args, **kwargs):
495 """Like check_call but doesn't throw on failure."""
496 kwargs.setdefault('cwd', self.project_path)
497 kwargs.setdefault('stdout', self.VOID)
498 return subprocess2.call(['git'] + args, **kwargs)
499
500 def _check_output_git(self, args, **kwargs):
501 kwargs.setdefault('cwd', self.project_path)
maruel@chromium.org87e6d332011-09-09 19:01:28 +0000502 return subprocess2.check_output(
503 ['git'] + args, stderr=subprocess2.STDOUT, **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000504
505 def _branches(self):
506 """Returns the list of branches and the active one."""
507 out = self._check_output_git(['branch']).splitlines(False)
508 branches = [l[2:] for l in out]
509 active = None
510 for l in out:
511 if l.startswith('*'):
512 active = l[2:]
513 break
514 return branches, active
515
516
517class GitSvnCheckoutBase(GitCheckoutBase, SvnMixIn):
518 """Base class for git-svn checkout. Not to be used as-is."""
519 def __init__(self,
520 root_dir, project_name, remote_branch,
521 commit_user, commit_pwd,
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000522 svn_url, trunk, post_processors=None):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000523 """trunk is optional."""
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000524 GitCheckoutBase.__init__(
525 self, root_dir, project_name + '.git', remote_branch, post_processors)
526 SvnMixIn.__init__(self)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000527 self.commit_user = commit_user
528 self.commit_pwd = commit_pwd
529 # svn_url in this case is the root of the svn repository.
530 self.svn_url = svn_url
531 self.trunk = trunk
532 assert bool(self.commit_user) >= bool(self.commit_pwd)
533 assert self.svn_url
534 assert self.trunk
535 self._cache_svn_auth()
536
maruel@chromium.org51919772011-06-12 01:27:42 +0000537 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000538 """Resets the git repository in a clean state."""
539 self._check_call_git(['reset', '--hard', '--quiet'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000540 if revision:
541 try:
542 revision = self._check_output_git(
543 ['svn', 'find-rev', 'r%d' % revision])
544 except subprocess.CalledProcessError:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000545 self._check_call_git(
maruel@chromium.org51919772011-06-12 01:27:42 +0000546 ['fetch', self.remote, self.remote_branch, '--quiet'])
547 revision = self._check_output_git(
548 ['svn', 'find-rev', 'r%d' % revision])
549 super(GitSvnCheckoutBase, self).prepare(revision)
550 else:
551 branches, active = self._branches()
552 if active != 'master':
553 if not 'master' in branches:
554 self._check_call_git(
555 ['checkout', '--quiet', '-b', 'master',
556 '%s/%s' % (self.remote, self.remote_branch)])
557 else:
558 self._check_call_git(['checkout', 'master', '--force', '--quiet'])
559 # git svn rebase --quiet --quiet doesn't work, use two steps to silence
560 # it.
561 self._check_call_git_svn(['fetch', '--quiet', '--quiet'])
562 self._check_call_git(
563 ['rebase', '--quiet', '--quiet',
564 '%s/%s' % (self.remote, self.remote_branch)])
565 if self.working_branch in branches:
566 self._call_git(['branch', '-D', self.working_branch])
567 return self._get_revision()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000568
569 def _git_svn_info(self, key):
570 """Calls git svn info. This doesn't support nor need --config-dir."""
571 return self._parse_svn_info(self._check_output_git(['svn', 'info']), key)
572
573 def commit(self, commit_message, user):
574 """Commits a patch."""
575 logging.info('Committing patch for %s' % user)
576 # Fix the commit message and author. It returns the git hash, which we
577 # ignore unless it's None.
578 if not super(GitSvnCheckoutBase, self).commit(commit_message, user):
579 return None
580 # TODO(maruel): git-svn ignores --config-dir as of git-svn version 1.7.4 and
581 # doesn't support --with-revprop.
582 # Either learn perl and upstream or suck it.
583 kwargs = {}
584 if self.commit_pwd:
585 kwargs['stdin'] = self.commit_pwd + '\n'
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000586 kwargs['stderr'] = subprocess2.STDOUT
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000587 self._check_call_git_svn(
588 ['dcommit', '--rmdir', '--find-copies-harder',
589 '--username', self.commit_user],
590 **kwargs)
591 revision = int(self._git_svn_info('revision'))
592 return revision
593
594 def _cache_svn_auth(self):
595 """Caches the svn credentials. It is necessary since git-svn doesn't prompt
596 for it."""
597 if not self.commit_user or not self.commit_pwd:
598 return
599 # Use capture to lower noise in logs.
600 self._check_output_svn(['ls', self.svn_url], cwd=None)
601
602 def _check_call_git_svn(self, args, **kwargs):
603 """Handles svn authentication while calling git svn."""
604 args = ['svn'] + args
605 if not self.svn_config.default:
606 args.extend(['--config-dir', self.svn_config.svn_config_dir])
607 return self._check_call_git(args, **kwargs)
608
609 def _get_revision(self):
610 revision = int(self._git_svn_info('revision'))
611 if revision != self._last_seen_revision:
maruel@chromium.org51919772011-06-12 01:27:42 +0000612 logging.info('Updated to revision %d' % revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000613 self._last_seen_revision = revision
614 return revision
615
616
617class GitSvnPremadeCheckout(GitSvnCheckoutBase):
618 """Manages a git-svn clone made out from an initial git-svn seed.
619
620 This class is very similar to GitSvnCheckout but is faster to bootstrap
621 because it starts right off with an existing git-svn clone.
622 """
623 def __init__(self,
624 root_dir, project_name, remote_branch,
625 commit_user, commit_pwd,
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000626 svn_url, trunk, git_url, post_processors=None):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000627 super(GitSvnPremadeCheckout, self).__init__(
628 root_dir, project_name, remote_branch,
629 commit_user, commit_pwd,
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000630 svn_url, trunk, post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000631 self.git_url = git_url
632 assert self.git_url
633
maruel@chromium.org51919772011-06-12 01:27:42 +0000634 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000635 """Creates the initial checkout for the repo."""
636 if not os.path.isdir(self.project_path):
637 logging.info('Checking out %s in %s' %
638 (self.project_name, self.project_path))
639 assert self.remote == 'origin'
640 # self.project_path doesn't exist yet.
641 self._check_call_git(
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000642 ['clone', self.git_url, self.project_name, '--quiet'],
643 cwd=self.root_dir,
644 stderr=subprocess2.STDOUT)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000645 try:
646 configured_svn_url = self._check_output_git(
647 ['config', 'svn-remote.svn.url']).strip()
648 except subprocess.CalledProcessError:
649 configured_svn_url = ''
650
651 if configured_svn_url.strip() != self.svn_url:
652 self._check_call_git_svn(
653 ['init',
654 '--prefix', self.remote + '/',
655 '-T', self.trunk,
656 self.svn_url])
657 self._check_call_git_svn(['fetch'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000658 return super(GitSvnPremadeCheckout, self).prepare(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000659
660
661class GitSvnCheckout(GitSvnCheckoutBase):
662 """Manages a git-svn clone.
663
664 Using git-svn hides some of the complexity of using a svn checkout.
665 """
666 def __init__(self,
667 root_dir, project_name,
668 commit_user, commit_pwd,
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000669 svn_url, trunk, post_processors=None):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000670 super(GitSvnCheckout, self).__init__(
671 root_dir, project_name, 'trunk',
672 commit_user, commit_pwd,
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000673 svn_url, trunk, post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000674
maruel@chromium.org51919772011-06-12 01:27:42 +0000675 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000676 """Creates the initial checkout for the repo."""
maruel@chromium.org51919772011-06-12 01:27:42 +0000677 assert not revision, 'Implement revision if necessary'
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000678 if not os.path.isdir(self.project_path):
679 logging.info('Checking out %s in %s' %
680 (self.project_name, self.project_path))
681 # TODO: Create a shallow clone.
682 # self.project_path doesn't exist yet.
683 self._check_call_git_svn(
684 ['clone',
685 '--prefix', self.remote + '/',
686 '-T', self.trunk,
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000687 self.svn_url, self.project_path,
688 '--quiet'],
689 cwd=self.root_dir,
690 stderr=subprocess2.STDOUT)
maruel@chromium.org51919772011-06-12 01:27:42 +0000691 return super(GitSvnCheckout, self).prepare(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000692
693
694class ReadOnlyCheckout(object):
695 """Converts a checkout into a read-only one."""
696 def __init__(self, checkout):
maruel@chromium.orga5129fb2011-06-20 18:36:25 +0000697 super(ReadOnlyCheckout, self).__init__()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000698 self.checkout = checkout
699
maruel@chromium.org51919772011-06-12 01:27:42 +0000700 def prepare(self, revision):
701 return self.checkout.prepare(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000702
703 def get_settings(self, key):
704 return self.checkout.get_settings(key)
705
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000706 def apply_patch(self, patches):
707 return self.checkout.apply_patch(patches)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000708
709 def commit(self, message, user): # pylint: disable=R0201
710 logging.info('Would have committed for %s with message: %s' % (
711 user, message))
712 return 'FAKE'
713
714 @property
715 def project_name(self):
716 return self.checkout.project_name
717
718 @property
719 def project_path(self):
720 return self.checkout.project_path