blob: 09ea9c3fabba116c7d25c571260ae198f98aedee [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
62 def __init__(self, root_dir, project_name):
63 self.root_dir = root_dir
64 self.project_name = project_name
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +000065 if self.project_name is None:
66 self.project_path = self.root_dir
67 else:
68 self.project_path = os.path.join(self.root_dir, self.project_name)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000069 # Only used for logging purposes.
70 self._last_seen_revision = None
71 assert self.root_dir
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000072 assert self.project_path
73
74 def get_settings(self, key):
75 return get_code_review_setting(self.project_path, key)
76
77 def prepare(self):
78 """Checks out a clean copy of the tree and removes any local modification.
79
80 This function shouldn't throw unless the remote repository is inaccessible,
81 there is no free disk space or hard issues like that.
82 """
83 raise NotImplementedError()
84
maruel@chromium.org8a1396c2011-04-22 00:14:24 +000085 def apply_patch(self, patches, post_processor=None):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000086 """Applies a patch and returns the list of modified files.
87
88 This function should throw patch.UnsupportedPatchFormat or
89 PatchApplicationFailed when relevant.
maruel@chromium.org8a1396c2011-04-22 00:14:24 +000090
91 Args:
92 patches: patch.PatchSet object.
93 post_processor: list of lambda(checkout, patches) to call on each of the
94 modified files.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000095 """
96 raise NotImplementedError()
97
98 def commit(self, commit_message, user):
99 """Commits the patch upstream, while impersonating 'user'."""
100 raise NotImplementedError()
101
102
103class RawCheckout(CheckoutBase):
104 """Used to apply a patch locally without any intent to commit it.
105
106 To be used by the try server.
107 """
108 def prepare(self):
109 """Stubbed out."""
110 pass
111
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000112 def apply_patch(self, patches, post_processor=None):
113 """Ignores svn properties."""
114 post_processor = post_processor or []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000115 for p in patches:
116 try:
117 stdout = ''
118 filename = os.path.join(self.project_path, p.filename)
119 if p.is_delete:
120 os.remove(filename)
121 else:
122 dirname = os.path.dirname(p.filename)
123 full_dir = os.path.join(self.project_path, dirname)
124 if dirname and not os.path.isdir(full_dir):
125 os.makedirs(full_dir)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000126
127 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000128 if p.is_binary:
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000129 with open(filepath, 'wb') as f:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000130 f.write(p.get())
131 else:
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000132 if p.diff_hunks:
133 stdout = subprocess2.check_output(
134 ['patch', '-p%s' % p.patchlevel],
135 stdin=p.get(),
136 cwd=self.project_path)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000137 elif p.is_new and not os.path.exists(filepath):
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000138 # There is only a header. Just create the file.
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000139 open(filepath, 'w').close()
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000140 for post in post_processor:
141 post(self, p)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000142 except OSError, e:
143 raise PatchApplicationFailed(p.filename, '%s%s' % (stdout, e))
144 except subprocess.CalledProcessError, e:
145 raise PatchApplicationFailed(
146 p.filename, '%s%s' % (stdout, getattr(e, 'stdout', None)))
147
148 def commit(self, commit_message, user):
149 """Stubbed out."""
150 raise NotImplementedError('RawCheckout can\'t commit')
151
152
153class SvnConfig(object):
154 """Parses a svn configuration file."""
155 def __init__(self, svn_config_dir=None):
156 self.svn_config_dir = svn_config_dir
157 self.default = not bool(self.svn_config_dir)
158 if not self.svn_config_dir:
159 if sys.platform == 'win32':
160 self.svn_config_dir = os.path.join(os.environ['APPDATA'], 'Subversion')
161 else:
162 self.svn_config_dir = os.path.join(os.environ['HOME'], '.subversion')
163 svn_config_file = os.path.join(self.svn_config_dir, 'config')
164 parser = ConfigParser.SafeConfigParser()
165 if os.path.isfile(svn_config_file):
166 parser.read(svn_config_file)
167 else:
168 parser.add_section('auto-props')
169 self.auto_props = dict(parser.items('auto-props'))
170
171
172class SvnMixIn(object):
173 """MixIn class to add svn commands common to both svn and git-svn clients."""
174 # These members need to be set by the subclass.
175 commit_user = None
176 commit_pwd = None
177 svn_url = None
178 project_path = None
179 # Override at class level when necessary. If used, --non-interactive is
180 # implied.
181 svn_config = SvnConfig()
182 # Set to True when non-interactivity is necessary but a custom subversion
183 # configuration directory is not necessary.
184 non_interactive = False
185
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000186 def _add_svn_flags(self, args, non_interactive, credentials=True):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000187 args = ['svn'] + args
188 if not self.svn_config.default:
189 args.extend(['--config-dir', self.svn_config.svn_config_dir])
190 if not self.svn_config.default or self.non_interactive or non_interactive:
191 args.append('--non-interactive')
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000192 if credentials:
193 if self.commit_user:
194 args.extend(['--username', self.commit_user])
195 if self.commit_pwd:
196 args.extend(['--password', self.commit_pwd])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000197 return args
198
199 def _check_call_svn(self, args, **kwargs):
200 """Runs svn and throws an exception if the command failed."""
201 kwargs.setdefault('cwd', self.project_path)
202 kwargs.setdefault('stdout', self.VOID)
maruel@chromium.org0bcd1d32011-04-26 15:55:49 +0000203 return subprocess2.check_call_out(
204 self._add_svn_flags(args, False), **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000205
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000206 def _check_output_svn(self, args, credentials=True, **kwargs):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000207 """Runs svn and throws an exception if the command failed.
208
209 Returns the output.
210 """
211 kwargs.setdefault('cwd', self.project_path)
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000212 return subprocess2.check_output(
213 self._add_svn_flags(args, True, credentials), **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000214
215 @staticmethod
216 def _parse_svn_info(output, key):
217 """Returns value for key from svn info output.
218
219 Case insensitive.
220 """
221 values = {}
222 key = key.lower()
223 for line in output.splitlines(False):
224 if not line:
225 continue
226 k, v = line.split(':', 1)
227 k = k.strip().lower()
228 v = v.strip()
229 assert not k in values
230 values[k] = v
231 return values.get(key, None)
232
233
234class SvnCheckout(CheckoutBase, SvnMixIn):
235 """Manages a subversion checkout."""
236 def __init__(self, root_dir, project_name, commit_user, commit_pwd, svn_url):
237 super(SvnCheckout, self).__init__(root_dir, project_name)
238 self.commit_user = commit_user
239 self.commit_pwd = commit_pwd
240 self.svn_url = svn_url
241 assert bool(self.commit_user) >= bool(self.commit_pwd)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000242
243 def prepare(self):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000244 # Will checkout if the directory is not present.
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000245 assert self.svn_url
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000246 if not os.path.isdir(self.project_path):
247 logging.info('Checking out %s in %s' %
248 (self.project_name, self.project_path))
249 revision = self._revert()
250 if revision != self._last_seen_revision:
251 logging.info('Updated at revision %d' % revision)
252 self._last_seen_revision = revision
253 return revision
254
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000255 def apply_patch(self, patches, post_processor=None):
256 post_processor = post_processor or []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000257 for p in patches:
258 try:
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000259 # It is important to use credentials=False otherwise credentials could
260 # leak in the error message. Credentials are not necessary here for the
261 # following commands anyway.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000262 stdout = ''
263 if p.is_delete:
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000264 stdout += self._check_output_svn(
265 ['delete', p.filename, '--force'], credentials=False)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000266 else:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000267 # svn add while creating directories otherwise svn add on the
268 # contained files will silently fail.
269 # First, find the root directory that exists.
270 dirname = os.path.dirname(p.filename)
271 dirs_to_create = []
272 while (dirname and
273 not os.path.isdir(os.path.join(self.project_path, dirname))):
274 dirs_to_create.append(dirname)
275 dirname = os.path.dirname(dirname)
276 for dir_to_create in reversed(dirs_to_create):
277 os.mkdir(os.path.join(self.project_path, dir_to_create))
278 stdout += self._check_output_svn(
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000279 ['add', dir_to_create, '--force'], credentials=False)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000280
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000281 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000282 if p.is_binary:
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000283 with open(filepath, 'wb') as f:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000284 f.write(p.get())
285 else:
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000286 if p.diff_hunks:
287 cmd = ['patch', '-p%s' % p.patchlevel, '--forward', '--force']
288 stdout += subprocess2.check_output(
289 cmd, stdin=p.get(), cwd=self.project_path)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000290 elif p.is_new and not os.path.exists(filepath):
291 # There is only a header. Just create the file if it doesn't
292 # exist.
293 open(filepath, 'w').close()
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000294 if p.is_new:
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000295 stdout += self._check_output_svn(
296 ['add', p.filename, '--force'], credentials=False)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000297 for prop in p.svn_properties:
298 stdout += self._check_output_svn(
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000299 ['propset', prop[0], prop[1], p.filename], credentials=False)
300 for prop, values in self.svn_config.auto_props.iteritems():
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000301 if fnmatch.fnmatch(p.filename, prop):
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000302 for value in values.split(';'):
303 if '=' not in value:
304 params = [value, '*']
305 else:
306 params = value.split('=', 1)
307 stdout += self._check_output_svn(
308 ['propset'] + params + [p.filename], credentials=False)
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000309 for post in post_processor:
310 post(self, p)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000311 except OSError, e:
312 raise PatchApplicationFailed(p.filename, '%s%s' % (stdout, e))
313 except subprocess.CalledProcessError, e:
314 raise PatchApplicationFailed(
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000315 p.filename,
316 'While running %s;\n%s%s' % (
317 ' '.join(e.cmd), stdout, getattr(e, 'stdout', '')))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000318
319 def commit(self, commit_message, user):
320 logging.info('Committing patch for %s' % user)
321 assert self.commit_user
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000322 assert isinstance(commit_message, unicode)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000323 handle, commit_filename = tempfile.mkstemp(text=True)
324 try:
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000325 # Shouldn't assume default encoding is UTF-8. But really, if you are using
326 # anything else, you are living in another world.
327 os.write(handle, commit_message.encode('utf-8'))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000328 os.close(handle)
329 # When committing, svn won't update the Revision metadata of the checkout,
330 # so if svn commit returns "Committed revision 3.", svn info will still
331 # return "Revision: 2". Since running svn update right after svn commit
332 # creates a race condition with other committers, this code _must_ parse
333 # the output of svn commit and use a regexp to grab the revision number.
334 # Note that "Committed revision N." is localized but subprocess2 forces
335 # LANGUAGE=en.
336 args = ['commit', '--file', commit_filename]
337 # realauthor is parsed by a server-side hook.
338 if user and user != self.commit_user:
339 args.extend(['--with-revprop', 'realauthor=%s' % user])
340 out = self._check_output_svn(args)
341 finally:
342 os.remove(commit_filename)
343 lines = filter(None, out.splitlines())
344 match = re.match(r'^Committed revision (\d+).$', lines[-1])
345 if not match:
346 raise PatchApplicationFailed(
347 None,
348 'Couldn\'t make sense out of svn commit message:\n' + out)
349 return int(match.group(1))
350
351 def _revert(self):
352 """Reverts local modifications or checks out if the directory is not
353 present. Use depot_tools's functionality to do this.
354 """
355 flags = ['--ignore-externals']
356 if not os.path.isdir(self.project_path):
357 logging.info(
358 'Directory %s is not present, checking it out.' % self.project_path)
359 self._check_call_svn(
360 ['checkout', self.svn_url, self.project_path] + flags, cwd=None)
361 else:
362 scm.SVN.Revert(self.project_path)
363 # Revive files that were deleted in scm.SVN.Revert().
364 self._check_call_svn(['update', '--force'] + flags)
365
366 out = self._check_output_svn(['info', '.'])
367 return int(self._parse_svn_info(out, 'revision'))
368
369
370class GitCheckoutBase(CheckoutBase):
371 """Base class for git checkout. Not to be used as-is."""
372 def __init__(self, root_dir, project_name, remote_branch):
373 super(GitCheckoutBase, self).__init__(root_dir, project_name)
374 # There is no reason to not hardcode it.
375 self.remote = 'origin'
376 self.remote_branch = remote_branch
377 self.working_branch = 'working_branch'
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000378
379 def prepare(self):
380 """Resets the git repository in a clean state.
381
382 Checks it out if not present and deletes the working branch.
383 """
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000384 assert self.remote_branch
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000385 assert os.path.isdir(self.project_path)
386 self._check_call_git(['reset', '--hard', '--quiet'])
387 branches, active = self._branches()
388 if active != 'master':
389 self._check_call_git(['checkout', 'master', '--force', '--quiet'])
390 self._check_call_git(['pull', self.remote, self.remote_branch, '--quiet'])
391 if self.working_branch in branches:
392 self._call_git(['branch', '-D', self.working_branch])
393
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000394 def apply_patch(self, patches, post_processor=None):
395 """Applies a patch on 'working_branch' and switch to it.
396
397 Also commits the changes on the local branch.
398
399 Ignores svn properties and raise an exception on unexpected ones.
400 """
401 post_processor = post_processor or []
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000402 # It this throws, the checkout is corrupted. Maybe worth deleting it and
403 # trying again?
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000404 if self.remote_branch:
405 self._check_call_git(
406 ['checkout', '-b', self.working_branch,
407 '%s/%s' % (self.remote, self.remote_branch), '--quiet'])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000408 for p in patches:
409 try:
410 stdout = ''
411 if p.is_delete:
412 stdout += self._check_output_git(['rm', p.filename])
413 else:
414 dirname = os.path.dirname(p.filename)
415 full_dir = os.path.join(self.project_path, dirname)
416 if dirname and not os.path.isdir(full_dir):
417 os.makedirs(full_dir)
418 if p.is_binary:
419 with open(os.path.join(self.project_path, p.filename), 'wb') as f:
420 f.write(p.get())
421 stdout += self._check_output_git(['add', p.filename])
422 else:
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000423 # No need to do anything special with p.is_new or if not
424 # p.diff_hunks. git apply manages all that already.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000425 stdout += self._check_output_git(
426 ['apply', '--index', '-p%s' % p.patchlevel], stdin=p.get())
427 for prop in p.svn_properties:
428 # Ignore some known auto-props flags through .subversion/config,
429 # bails out on the other ones.
430 # TODO(maruel): Read ~/.subversion/config and detect the rules that
431 # applies here to figure out if the property will be correctly
432 # handled.
433 if not prop[0] in ('svn:eol-style', 'svn:executable'):
434 raise patch.UnsupportedPatchFormat(
435 p.filename,
436 'Cannot apply svn property %s to file %s.' % (
437 prop[0], p.filename))
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000438 for post in post_processor:
439 post(self, p)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000440 except OSError, e:
441 raise PatchApplicationFailed(p.filename, '%s%s' % (stdout, e))
442 except subprocess.CalledProcessError, e:
443 raise PatchApplicationFailed(
444 p.filename, '%s%s' % (stdout, getattr(e, 'stdout', None)))
445 # Once all the patches are processed and added to the index, commit the
446 # index.
447 self._check_call_git(['commit', '-m', 'Committed patch'])
448 # TODO(maruel): Weirdly enough they don't match, need to investigate.
449 #found_files = self._check_output_git(
450 # ['diff', 'master', '--name-only']).splitlines(False)
451 #assert sorted(patches.filenames) == sorted(found_files), (
452 # sorted(out), sorted(found_files))
453
454 def commit(self, commit_message, user):
455 """Updates the commit message.
456
457 Subclass needs to dcommit or push.
458 """
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000459 assert isinstance(commit_message, unicode)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000460 self._check_call_git(['commit', '--amend', '-m', commit_message])
461 return self._check_output_git(['rev-parse', 'HEAD']).strip()
462
463 def _check_call_git(self, args, **kwargs):
464 kwargs.setdefault('cwd', self.project_path)
465 kwargs.setdefault('stdout', self.VOID)
maruel@chromium.org0bcd1d32011-04-26 15:55:49 +0000466 return subprocess2.check_call_out(['git'] + args, **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000467
468 def _call_git(self, args, **kwargs):
469 """Like check_call but doesn't throw on failure."""
470 kwargs.setdefault('cwd', self.project_path)
471 kwargs.setdefault('stdout', self.VOID)
472 return subprocess2.call(['git'] + args, **kwargs)
473
474 def _check_output_git(self, args, **kwargs):
475 kwargs.setdefault('cwd', self.project_path)
476 return subprocess2.check_output(['git'] + args, **kwargs)
477
478 def _branches(self):
479 """Returns the list of branches and the active one."""
480 out = self._check_output_git(['branch']).splitlines(False)
481 branches = [l[2:] for l in out]
482 active = None
483 for l in out:
484 if l.startswith('*'):
485 active = l[2:]
486 break
487 return branches, active
488
489
490class GitSvnCheckoutBase(GitCheckoutBase, SvnMixIn):
491 """Base class for git-svn checkout. Not to be used as-is."""
492 def __init__(self,
493 root_dir, project_name, remote_branch,
494 commit_user, commit_pwd,
495 svn_url, trunk):
496 """trunk is optional."""
497 super(GitSvnCheckoutBase, self).__init__(
498 root_dir, project_name + '.git', remote_branch)
499 self.commit_user = commit_user
500 self.commit_pwd = commit_pwd
501 # svn_url in this case is the root of the svn repository.
502 self.svn_url = svn_url
503 self.trunk = trunk
504 assert bool(self.commit_user) >= bool(self.commit_pwd)
505 assert self.svn_url
506 assert self.trunk
507 self._cache_svn_auth()
508
509 def prepare(self):
510 """Resets the git repository in a clean state."""
511 self._check_call_git(['reset', '--hard', '--quiet'])
512 branches, active = self._branches()
513 if active != 'master':
514 if not 'master' in branches:
515 self._check_call_git(
516 ['checkout', '--quiet', '-b', 'master',
517 '%s/%s' % (self.remote, self.remote_branch)])
518 else:
519 self._check_call_git(['checkout', 'master', '--force', '--quiet'])
520 # git svn rebase --quiet --quiet doesn't work, use two steps to silence it.
521 self._check_call_git_svn(['fetch', '--quiet', '--quiet'])
522 self._check_call_git(
523 ['rebase', '--quiet', '--quiet',
524 '%s/%s' % (self.remote, self.remote_branch)])
525 if self.working_branch in branches:
526 self._call_git(['branch', '-D', self.working_branch])
527 return int(self._git_svn_info('revision'))
528
529 def _git_svn_info(self, key):
530 """Calls git svn info. This doesn't support nor need --config-dir."""
531 return self._parse_svn_info(self._check_output_git(['svn', 'info']), key)
532
533 def commit(self, commit_message, user):
534 """Commits a patch."""
535 logging.info('Committing patch for %s' % user)
536 # Fix the commit message and author. It returns the git hash, which we
537 # ignore unless it's None.
538 if not super(GitSvnCheckoutBase, self).commit(commit_message, user):
539 return None
540 # TODO(maruel): git-svn ignores --config-dir as of git-svn version 1.7.4 and
541 # doesn't support --with-revprop.
542 # Either learn perl and upstream or suck it.
543 kwargs = {}
544 if self.commit_pwd:
545 kwargs['stdin'] = self.commit_pwd + '\n'
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000546 kwargs['stderr'] = subprocess2.STDOUT
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000547 self._check_call_git_svn(
548 ['dcommit', '--rmdir', '--find-copies-harder',
549 '--username', self.commit_user],
550 **kwargs)
551 revision = int(self._git_svn_info('revision'))
552 return revision
553
554 def _cache_svn_auth(self):
555 """Caches the svn credentials. It is necessary since git-svn doesn't prompt
556 for it."""
557 if not self.commit_user or not self.commit_pwd:
558 return
559 # Use capture to lower noise in logs.
560 self._check_output_svn(['ls', self.svn_url], cwd=None)
561
562 def _check_call_git_svn(self, args, **kwargs):
563 """Handles svn authentication while calling git svn."""
564 args = ['svn'] + args
565 if not self.svn_config.default:
566 args.extend(['--config-dir', self.svn_config.svn_config_dir])
567 return self._check_call_git(args, **kwargs)
568
569 def _get_revision(self):
570 revision = int(self._git_svn_info('revision'))
571 if revision != self._last_seen_revision:
572 logging.info('Updated at revision %d' % revision)
573 self._last_seen_revision = revision
574 return revision
575
576
577class GitSvnPremadeCheckout(GitSvnCheckoutBase):
578 """Manages a git-svn clone made out from an initial git-svn seed.
579
580 This class is very similar to GitSvnCheckout but is faster to bootstrap
581 because it starts right off with an existing git-svn clone.
582 """
583 def __init__(self,
584 root_dir, project_name, remote_branch,
585 commit_user, commit_pwd,
586 svn_url, trunk, git_url):
587 super(GitSvnPremadeCheckout, self).__init__(
588 root_dir, project_name, remote_branch,
589 commit_user, commit_pwd,
590 svn_url, trunk)
591 self.git_url = git_url
592 assert self.git_url
593
594 def prepare(self):
595 """Creates the initial checkout for the repo."""
596 if not os.path.isdir(self.project_path):
597 logging.info('Checking out %s in %s' %
598 (self.project_name, self.project_path))
599 assert self.remote == 'origin'
600 # self.project_path doesn't exist yet.
601 self._check_call_git(
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000602 ['clone', self.git_url, self.project_name, '--quiet'],
603 cwd=self.root_dir,
604 stderr=subprocess2.STDOUT)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000605 try:
606 configured_svn_url = self._check_output_git(
607 ['config', 'svn-remote.svn.url']).strip()
608 except subprocess.CalledProcessError:
609 configured_svn_url = ''
610
611 if configured_svn_url.strip() != self.svn_url:
612 self._check_call_git_svn(
613 ['init',
614 '--prefix', self.remote + '/',
615 '-T', self.trunk,
616 self.svn_url])
617 self._check_call_git_svn(['fetch'])
618 super(GitSvnPremadeCheckout, self).prepare()
619 return self._get_revision()
620
621
622class GitSvnCheckout(GitSvnCheckoutBase):
623 """Manages a git-svn clone.
624
625 Using git-svn hides some of the complexity of using a svn checkout.
626 """
627 def __init__(self,
628 root_dir, project_name,
629 commit_user, commit_pwd,
630 svn_url, trunk):
631 super(GitSvnCheckout, self).__init__(
632 root_dir, project_name, 'trunk',
633 commit_user, commit_pwd,
634 svn_url, trunk)
635
636 def prepare(self):
637 """Creates the initial checkout for the repo."""
638 if not os.path.isdir(self.project_path):
639 logging.info('Checking out %s in %s' %
640 (self.project_name, self.project_path))
641 # TODO: Create a shallow clone.
642 # self.project_path doesn't exist yet.
643 self._check_call_git_svn(
644 ['clone',
645 '--prefix', self.remote + '/',
646 '-T', self.trunk,
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000647 self.svn_url, self.project_path,
648 '--quiet'],
649 cwd=self.root_dir,
650 stderr=subprocess2.STDOUT)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000651 super(GitSvnCheckout, self).prepare()
652 return self._get_revision()
653
654
655class ReadOnlyCheckout(object):
656 """Converts a checkout into a read-only one."""
657 def __init__(self, checkout):
658 self.checkout = checkout
659
660 def prepare(self):
661 return self.checkout.prepare()
662
663 def get_settings(self, key):
664 return self.checkout.get_settings(key)
665
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000666 def apply_patch(self, patches, post_processor=None):
667 return self.checkout.apply_patch(patches, post_processor)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000668
669 def commit(self, message, user): # pylint: disable=R0201
670 logging.info('Would have committed for %s with message: %s' % (
671 user, message))
672 return 'FAKE'
673
674 @property
675 def project_name(self):
676 return self.checkout.project_name
677
678 @property
679 def project_path(self):
680 return self.checkout.project_path