blob: 9f21acc906d4c6ff19146db67e677169c75d73ca [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.orgdfaecd22011-04-21 00:33:31 +000068 self.root_dir = root_dir
69 self.project_name = project_name
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +000070 if self.project_name is None:
71 self.project_path = self.root_dir
72 else:
73 self.project_path = os.path.join(self.root_dir, self.project_name)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000074 # Only used for logging purposes.
75 self._last_seen_revision = None
maruel@chromium.org6ed8b502011-06-12 01:05:35 +000076 self.post_processors = None
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000077 assert self.root_dir
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000078 assert self.project_path
79
80 def get_settings(self, key):
81 return get_code_review_setting(self.project_path, key)
82
83 def prepare(self):
84 """Checks out a clean copy of the tree and removes any local modification.
85
86 This function shouldn't throw unless the remote repository is inaccessible,
87 there is no free disk space or hard issues like that.
88 """
89 raise NotImplementedError()
90
maruel@chromium.org6ed8b502011-06-12 01:05:35 +000091 def apply_patch(self, patches):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000092 """Applies a patch and returns the list of modified files.
93
94 This function should throw patch.UnsupportedPatchFormat or
95 PatchApplicationFailed when relevant.
maruel@chromium.org8a1396c2011-04-22 00:14:24 +000096
97 Args:
98 patches: patch.PatchSet object.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000099 """
100 raise NotImplementedError()
101
102 def commit(self, commit_message, user):
103 """Commits the patch upstream, while impersonating 'user'."""
104 raise NotImplementedError()
105
106
107class RawCheckout(CheckoutBase):
108 """Used to apply a patch locally without any intent to commit it.
109
110 To be used by the try server.
111 """
112 def prepare(self):
113 """Stubbed out."""
114 pass
115
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000116 def apply_patch(self, patches):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000117 """Ignores svn properties."""
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000118 for p in patches:
119 try:
120 stdout = ''
121 filename = os.path.join(self.project_path, p.filename)
122 if p.is_delete:
123 os.remove(filename)
124 else:
125 dirname = os.path.dirname(p.filename)
126 full_dir = os.path.join(self.project_path, dirname)
127 if dirname and not os.path.isdir(full_dir):
128 os.makedirs(full_dir)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000129
130 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000131 if p.is_binary:
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000132 with open(filepath, 'wb') as f:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000133 f.write(p.get())
134 else:
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000135 if p.diff_hunks:
136 stdout = subprocess2.check_output(
137 ['patch', '-p%s' % p.patchlevel],
138 stdin=p.get(),
139 cwd=self.project_path)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000140 elif p.is_new and not os.path.exists(filepath):
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000141 # There is only a header. Just create the file.
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000142 open(filepath, 'w').close()
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000143 for post in (self.post_processors or []):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000144 post(self, p)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000145 except OSError, e:
146 raise PatchApplicationFailed(p.filename, '%s%s' % (stdout, e))
147 except subprocess.CalledProcessError, e:
148 raise PatchApplicationFailed(
149 p.filename, '%s%s' % (stdout, getattr(e, 'stdout', None)))
150
151 def commit(self, commit_message, user):
152 """Stubbed out."""
153 raise NotImplementedError('RawCheckout can\'t commit')
154
155
156class SvnConfig(object):
157 """Parses a svn configuration file."""
158 def __init__(self, svn_config_dir=None):
159 self.svn_config_dir = svn_config_dir
160 self.default = not bool(self.svn_config_dir)
161 if not self.svn_config_dir:
162 if sys.platform == 'win32':
163 self.svn_config_dir = os.path.join(os.environ['APPDATA'], 'Subversion')
164 else:
165 self.svn_config_dir = os.path.join(os.environ['HOME'], '.subversion')
166 svn_config_file = os.path.join(self.svn_config_dir, 'config')
167 parser = ConfigParser.SafeConfigParser()
168 if os.path.isfile(svn_config_file):
169 parser.read(svn_config_file)
170 else:
171 parser.add_section('auto-props')
172 self.auto_props = dict(parser.items('auto-props'))
173
174
175class SvnMixIn(object):
176 """MixIn class to add svn commands common to both svn and git-svn clients."""
177 # These members need to be set by the subclass.
178 commit_user = None
179 commit_pwd = None
180 svn_url = None
181 project_path = None
182 # Override at class level when necessary. If used, --non-interactive is
183 # implied.
184 svn_config = SvnConfig()
185 # Set to True when non-interactivity is necessary but a custom subversion
186 # configuration directory is not necessary.
187 non_interactive = False
188
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000189 def _add_svn_flags(self, args, non_interactive, credentials=True):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000190 args = ['svn'] + args
191 if not self.svn_config.default:
192 args.extend(['--config-dir', self.svn_config.svn_config_dir])
193 if not self.svn_config.default or self.non_interactive or non_interactive:
194 args.append('--non-interactive')
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000195 if credentials:
196 if self.commit_user:
197 args.extend(['--username', self.commit_user])
198 if self.commit_pwd:
199 args.extend(['--password', self.commit_pwd])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000200 return args
201
202 def _check_call_svn(self, args, **kwargs):
203 """Runs svn and throws an exception if the command failed."""
204 kwargs.setdefault('cwd', self.project_path)
205 kwargs.setdefault('stdout', self.VOID)
maruel@chromium.org0bcd1d32011-04-26 15:55:49 +0000206 return subprocess2.check_call_out(
207 self._add_svn_flags(args, False), **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000208
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000209 def _check_output_svn(self, args, credentials=True, **kwargs):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000210 """Runs svn and throws an exception if the command failed.
211
212 Returns the output.
213 """
214 kwargs.setdefault('cwd', self.project_path)
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000215 return subprocess2.check_output(
216 self._add_svn_flags(args, True, credentials), **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000217
218 @staticmethod
219 def _parse_svn_info(output, key):
220 """Returns value for key from svn info output.
221
222 Case insensitive.
223 """
224 values = {}
225 key = key.lower()
226 for line in output.splitlines(False):
227 if not line:
228 continue
229 k, v = line.split(':', 1)
230 k = k.strip().lower()
231 v = v.strip()
232 assert not k in values
233 values[k] = v
234 return values.get(key, None)
235
236
237class SvnCheckout(CheckoutBase, SvnMixIn):
238 """Manages a subversion checkout."""
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000239 def __init__(self, root_dir, project_name, commit_user, commit_pwd, svn_url,
240 post_processors=None):
241 super(SvnCheckout, self).__init__(root_dir, project_name, post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000242 self.commit_user = commit_user
243 self.commit_pwd = commit_pwd
244 self.svn_url = svn_url
245 assert bool(self.commit_user) >= bool(self.commit_pwd)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000246
247 def prepare(self):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000248 # Will checkout if the directory is not present.
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000249 assert self.svn_url
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000250 if not os.path.isdir(self.project_path):
251 logging.info('Checking out %s in %s' %
252 (self.project_name, self.project_path))
253 revision = self._revert()
254 if revision != self._last_seen_revision:
255 logging.info('Updated at revision %d' % revision)
256 self._last_seen_revision = revision
257 return revision
258
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000259 def apply_patch(self, patches):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000260 for p in patches:
261 try:
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000262 # It is important to use credentials=False otherwise credentials could
263 # leak in the error message. Credentials are not necessary here for the
264 # following commands anyway.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000265 stdout = ''
266 if p.is_delete:
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000267 stdout += self._check_output_svn(
268 ['delete', p.filename, '--force'], credentials=False)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000269 else:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000270 # svn add while creating directories otherwise svn add on the
271 # contained files will silently fail.
272 # First, find the root directory that exists.
273 dirname = os.path.dirname(p.filename)
274 dirs_to_create = []
275 while (dirname and
276 not os.path.isdir(os.path.join(self.project_path, dirname))):
277 dirs_to_create.append(dirname)
278 dirname = os.path.dirname(dirname)
279 for dir_to_create in reversed(dirs_to_create):
280 os.mkdir(os.path.join(self.project_path, dir_to_create))
281 stdout += self._check_output_svn(
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000282 ['add', dir_to_create, '--force'], credentials=False)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000283
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000284 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000285 if p.is_binary:
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000286 with open(filepath, 'wb') as f:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000287 f.write(p.get())
288 else:
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000289 if p.diff_hunks:
290 cmd = ['patch', '-p%s' % p.patchlevel, '--forward', '--force']
291 stdout += subprocess2.check_output(
292 cmd, stdin=p.get(), cwd=self.project_path)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000293 elif p.is_new and not os.path.exists(filepath):
294 # There is only a header. Just create the file if it doesn't
295 # exist.
296 open(filepath, 'w').close()
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000297 if p.is_new:
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000298 stdout += self._check_output_svn(
299 ['add', p.filename, '--force'], credentials=False)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000300 for prop in p.svn_properties:
301 stdout += self._check_output_svn(
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000302 ['propset', prop[0], prop[1], p.filename], credentials=False)
303 for prop, values in self.svn_config.auto_props.iteritems():
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000304 if fnmatch.fnmatch(p.filename, prop):
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000305 for value in values.split(';'):
306 if '=' not in value:
307 params = [value, '*']
308 else:
309 params = value.split('=', 1)
310 stdout += self._check_output_svn(
311 ['propset'] + params + [p.filename], credentials=False)
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000312 for post in (self.post_processors or []):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000313 post(self, p)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000314 except OSError, e:
315 raise PatchApplicationFailed(p.filename, '%s%s' % (stdout, e))
316 except subprocess.CalledProcessError, e:
317 raise PatchApplicationFailed(
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000318 p.filename,
319 'While running %s;\n%s%s' % (
320 ' '.join(e.cmd), stdout, getattr(e, 'stdout', '')))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000321
322 def commit(self, commit_message, user):
323 logging.info('Committing patch for %s' % user)
324 assert self.commit_user
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000325 assert isinstance(commit_message, unicode)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000326 handle, commit_filename = tempfile.mkstemp(text=True)
327 try:
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000328 # Shouldn't assume default encoding is UTF-8. But really, if you are using
329 # anything else, you are living in another world.
330 os.write(handle, commit_message.encode('utf-8'))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000331 os.close(handle)
332 # When committing, svn won't update the Revision metadata of the checkout,
333 # so if svn commit returns "Committed revision 3.", svn info will still
334 # return "Revision: 2". Since running svn update right after svn commit
335 # creates a race condition with other committers, this code _must_ parse
336 # the output of svn commit and use a regexp to grab the revision number.
337 # Note that "Committed revision N." is localized but subprocess2 forces
338 # LANGUAGE=en.
339 args = ['commit', '--file', commit_filename]
340 # realauthor is parsed by a server-side hook.
341 if user and user != self.commit_user:
342 args.extend(['--with-revprop', 'realauthor=%s' % user])
343 out = self._check_output_svn(args)
344 finally:
345 os.remove(commit_filename)
346 lines = filter(None, out.splitlines())
347 match = re.match(r'^Committed revision (\d+).$', lines[-1])
348 if not match:
349 raise PatchApplicationFailed(
350 None,
351 'Couldn\'t make sense out of svn commit message:\n' + out)
352 return int(match.group(1))
353
354 def _revert(self):
355 """Reverts local modifications or checks out if the directory is not
356 present. Use depot_tools's functionality to do this.
357 """
358 flags = ['--ignore-externals']
359 if not os.path.isdir(self.project_path):
360 logging.info(
361 'Directory %s is not present, checking it out.' % self.project_path)
362 self._check_call_svn(
363 ['checkout', self.svn_url, self.project_path] + flags, cwd=None)
364 else:
365 scm.SVN.Revert(self.project_path)
366 # Revive files that were deleted in scm.SVN.Revert().
367 self._check_call_svn(['update', '--force'] + flags)
368
369 out = self._check_output_svn(['info', '.'])
370 return int(self._parse_svn_info(out, 'revision'))
371
372
373class GitCheckoutBase(CheckoutBase):
374 """Base class for git checkout. Not to be used as-is."""
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000375 def __init__(self, root_dir, project_name, remote_branch,
376 post_processors=None):
377 super(GitCheckoutBase, self).__init__(
378 root_dir, project_name, post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000379 # There is no reason to not hardcode it.
380 self.remote = 'origin'
381 self.remote_branch = remote_branch
382 self.working_branch = 'working_branch'
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000383
384 def prepare(self):
385 """Resets the git repository in a clean state.
386
387 Checks it out if not present and deletes the working branch.
388 """
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000389 assert self.remote_branch
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000390 assert os.path.isdir(self.project_path)
391 self._check_call_git(['reset', '--hard', '--quiet'])
392 branches, active = self._branches()
393 if active != 'master':
394 self._check_call_git(['checkout', 'master', '--force', '--quiet'])
395 self._check_call_git(['pull', self.remote, self.remote_branch, '--quiet'])
396 if self.working_branch in branches:
397 self._call_git(['branch', '-D', self.working_branch])
398
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000399 def apply_patch(self, patches):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000400 """Applies a patch on 'working_branch' and switch to it.
401
402 Also commits the changes on the local branch.
403
404 Ignores svn properties and raise an exception on unexpected ones.
405 """
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000406 # It this throws, the checkout is corrupted. Maybe worth deleting it and
407 # trying again?
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000408 if self.remote_branch:
409 self._check_call_git(
410 ['checkout', '-b', self.working_branch,
411 '%s/%s' % (self.remote, self.remote_branch), '--quiet'])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000412 for p in patches:
413 try:
414 stdout = ''
415 if p.is_delete:
416 stdout += self._check_output_git(['rm', p.filename])
417 else:
418 dirname = os.path.dirname(p.filename)
419 full_dir = os.path.join(self.project_path, dirname)
420 if dirname and not os.path.isdir(full_dir):
421 os.makedirs(full_dir)
422 if p.is_binary:
423 with open(os.path.join(self.project_path, p.filename), 'wb') as f:
424 f.write(p.get())
425 stdout += self._check_output_git(['add', p.filename])
426 else:
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000427 # No need to do anything special with p.is_new or if not
428 # p.diff_hunks. git apply manages all that already.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000429 stdout += self._check_output_git(
430 ['apply', '--index', '-p%s' % p.patchlevel], stdin=p.get())
431 for prop in p.svn_properties:
432 # Ignore some known auto-props flags through .subversion/config,
433 # bails out on the other ones.
434 # TODO(maruel): Read ~/.subversion/config and detect the rules that
435 # applies here to figure out if the property will be correctly
436 # handled.
437 if not prop[0] in ('svn:eol-style', 'svn:executable'):
438 raise patch.UnsupportedPatchFormat(
439 p.filename,
440 'Cannot apply svn property %s to file %s.' % (
441 prop[0], p.filename))
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000442 for post in (self.post_processors or []):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000443 post(self, p)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000444 except OSError, e:
445 raise PatchApplicationFailed(p.filename, '%s%s' % (stdout, e))
446 except subprocess.CalledProcessError, e:
447 raise PatchApplicationFailed(
448 p.filename, '%s%s' % (stdout, getattr(e, 'stdout', None)))
449 # Once all the patches are processed and added to the index, commit the
450 # index.
451 self._check_call_git(['commit', '-m', 'Committed patch'])
452 # TODO(maruel): Weirdly enough they don't match, need to investigate.
453 #found_files = self._check_output_git(
454 # ['diff', 'master', '--name-only']).splitlines(False)
455 #assert sorted(patches.filenames) == sorted(found_files), (
456 # sorted(out), sorted(found_files))
457
458 def commit(self, commit_message, user):
459 """Updates the commit message.
460
461 Subclass needs to dcommit or push.
462 """
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000463 assert isinstance(commit_message, unicode)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000464 self._check_call_git(['commit', '--amend', '-m', commit_message])
465 return self._check_output_git(['rev-parse', 'HEAD']).strip()
466
467 def _check_call_git(self, args, **kwargs):
468 kwargs.setdefault('cwd', self.project_path)
469 kwargs.setdefault('stdout', self.VOID)
maruel@chromium.org0bcd1d32011-04-26 15:55:49 +0000470 return subprocess2.check_call_out(['git'] + args, **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000471
472 def _call_git(self, args, **kwargs):
473 """Like check_call but doesn't throw on failure."""
474 kwargs.setdefault('cwd', self.project_path)
475 kwargs.setdefault('stdout', self.VOID)
476 return subprocess2.call(['git'] + args, **kwargs)
477
478 def _check_output_git(self, args, **kwargs):
479 kwargs.setdefault('cwd', self.project_path)
480 return subprocess2.check_output(['git'] + args, **kwargs)
481
482 def _branches(self):
483 """Returns the list of branches and the active one."""
484 out = self._check_output_git(['branch']).splitlines(False)
485 branches = [l[2:] for l in out]
486 active = None
487 for l in out:
488 if l.startswith('*'):
489 active = l[2:]
490 break
491 return branches, active
492
493
494class GitSvnCheckoutBase(GitCheckoutBase, SvnMixIn):
495 """Base class for git-svn checkout. Not to be used as-is."""
496 def __init__(self,
497 root_dir, project_name, remote_branch,
498 commit_user, commit_pwd,
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000499 svn_url, trunk, post_processors=None):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000500 """trunk is optional."""
501 super(GitSvnCheckoutBase, self).__init__(
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000502 root_dir, project_name + '.git', remote_branch, post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000503 self.commit_user = commit_user
504 self.commit_pwd = commit_pwd
505 # svn_url in this case is the root of the svn repository.
506 self.svn_url = svn_url
507 self.trunk = trunk
508 assert bool(self.commit_user) >= bool(self.commit_pwd)
509 assert self.svn_url
510 assert self.trunk
511 self._cache_svn_auth()
512
513 def prepare(self):
514 """Resets the git repository in a clean state."""
515 self._check_call_git(['reset', '--hard', '--quiet'])
516 branches, active = self._branches()
517 if active != 'master':
518 if not 'master' in branches:
519 self._check_call_git(
520 ['checkout', '--quiet', '-b', 'master',
521 '%s/%s' % (self.remote, self.remote_branch)])
522 else:
523 self._check_call_git(['checkout', 'master', '--force', '--quiet'])
524 # git svn rebase --quiet --quiet doesn't work, use two steps to silence it.
525 self._check_call_git_svn(['fetch', '--quiet', '--quiet'])
526 self._check_call_git(
527 ['rebase', '--quiet', '--quiet',
528 '%s/%s' % (self.remote, self.remote_branch)])
529 if self.working_branch in branches:
530 self._call_git(['branch', '-D', self.working_branch])
531 return int(self._git_svn_info('revision'))
532
533 def _git_svn_info(self, key):
534 """Calls git svn info. This doesn't support nor need --config-dir."""
535 return self._parse_svn_info(self._check_output_git(['svn', 'info']), key)
536
537 def commit(self, commit_message, user):
538 """Commits a patch."""
539 logging.info('Committing patch for %s' % user)
540 # Fix the commit message and author. It returns the git hash, which we
541 # ignore unless it's None.
542 if not super(GitSvnCheckoutBase, self).commit(commit_message, user):
543 return None
544 # TODO(maruel): git-svn ignores --config-dir as of git-svn version 1.7.4 and
545 # doesn't support --with-revprop.
546 # Either learn perl and upstream or suck it.
547 kwargs = {}
548 if self.commit_pwd:
549 kwargs['stdin'] = self.commit_pwd + '\n'
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000550 kwargs['stderr'] = subprocess2.STDOUT
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000551 self._check_call_git_svn(
552 ['dcommit', '--rmdir', '--find-copies-harder',
553 '--username', self.commit_user],
554 **kwargs)
555 revision = int(self._git_svn_info('revision'))
556 return revision
557
558 def _cache_svn_auth(self):
559 """Caches the svn credentials. It is necessary since git-svn doesn't prompt
560 for it."""
561 if not self.commit_user or not self.commit_pwd:
562 return
563 # Use capture to lower noise in logs.
564 self._check_output_svn(['ls', self.svn_url], cwd=None)
565
566 def _check_call_git_svn(self, args, **kwargs):
567 """Handles svn authentication while calling git svn."""
568 args = ['svn'] + args
569 if not self.svn_config.default:
570 args.extend(['--config-dir', self.svn_config.svn_config_dir])
571 return self._check_call_git(args, **kwargs)
572
573 def _get_revision(self):
574 revision = int(self._git_svn_info('revision'))
575 if revision != self._last_seen_revision:
576 logging.info('Updated at revision %d' % revision)
577 self._last_seen_revision = revision
578 return revision
579
580
581class GitSvnPremadeCheckout(GitSvnCheckoutBase):
582 """Manages a git-svn clone made out from an initial git-svn seed.
583
584 This class is very similar to GitSvnCheckout but is faster to bootstrap
585 because it starts right off with an existing git-svn clone.
586 """
587 def __init__(self,
588 root_dir, project_name, remote_branch,
589 commit_user, commit_pwd,
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000590 svn_url, trunk, git_url, post_processors=None):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000591 super(GitSvnPremadeCheckout, self).__init__(
592 root_dir, project_name, remote_branch,
593 commit_user, commit_pwd,
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000594 svn_url, trunk, post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000595 self.git_url = git_url
596 assert self.git_url
597
598 def prepare(self):
599 """Creates the initial checkout for the repo."""
600 if not os.path.isdir(self.project_path):
601 logging.info('Checking out %s in %s' %
602 (self.project_name, self.project_path))
603 assert self.remote == 'origin'
604 # self.project_path doesn't exist yet.
605 self._check_call_git(
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000606 ['clone', self.git_url, self.project_name, '--quiet'],
607 cwd=self.root_dir,
608 stderr=subprocess2.STDOUT)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000609 try:
610 configured_svn_url = self._check_output_git(
611 ['config', 'svn-remote.svn.url']).strip()
612 except subprocess.CalledProcessError:
613 configured_svn_url = ''
614
615 if configured_svn_url.strip() != self.svn_url:
616 self._check_call_git_svn(
617 ['init',
618 '--prefix', self.remote + '/',
619 '-T', self.trunk,
620 self.svn_url])
621 self._check_call_git_svn(['fetch'])
622 super(GitSvnPremadeCheckout, self).prepare()
623 return self._get_revision()
624
625
626class GitSvnCheckout(GitSvnCheckoutBase):
627 """Manages a git-svn clone.
628
629 Using git-svn hides some of the complexity of using a svn checkout.
630 """
631 def __init__(self,
632 root_dir, project_name,
633 commit_user, commit_pwd,
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000634 svn_url, trunk, post_processors=None):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000635 super(GitSvnCheckout, self).__init__(
636 root_dir, project_name, 'trunk',
637 commit_user, commit_pwd,
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000638 svn_url, trunk, post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000639
640 def prepare(self):
641 """Creates the initial checkout for the repo."""
642 if not os.path.isdir(self.project_path):
643 logging.info('Checking out %s in %s' %
644 (self.project_name, self.project_path))
645 # TODO: Create a shallow clone.
646 # self.project_path doesn't exist yet.
647 self._check_call_git_svn(
648 ['clone',
649 '--prefix', self.remote + '/',
650 '-T', self.trunk,
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000651 self.svn_url, self.project_path,
652 '--quiet'],
653 cwd=self.root_dir,
654 stderr=subprocess2.STDOUT)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000655 super(GitSvnCheckout, self).prepare()
656 return self._get_revision()
657
658
659class ReadOnlyCheckout(object):
660 """Converts a checkout into a read-only one."""
661 def __init__(self, checkout):
662 self.checkout = checkout
663
664 def prepare(self):
665 return self.checkout.prepare()
666
667 def get_settings(self, key):
668 return self.checkout.get_settings(key)
669
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000670 def apply_patch(self, patches):
671 return self.checkout.apply_patch(patches)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000672
673 def commit(self, message, user): # pylint: disable=R0201
674 logging.info('Would have committed for %s with message: %s' % (
675 user, message))
676 return 'FAKE'
677
678 @property
679 def project_name(self):
680 return self.checkout.project_name
681
682 @property
683 def project_path(self):
684 return self.checkout.project_path