blob: 7b001a8289aa509c4e3092b34a8fd26981aaab15 [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
maruel@chromium.org51919772011-06-12 01:27:42 +000083 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000084 """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.
maruel@chromium.org51919772011-06-12 01:27:42 +000088
89 Args:
90 revision: The revision it should sync to, SCM specific.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000091 """
92 raise NotImplementedError()
93
maruel@chromium.org6ed8b502011-06-12 01:05:35 +000094 def apply_patch(self, patches):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +000095 """Applies a patch and returns the list of modified files.
96
97 This function should throw patch.UnsupportedPatchFormat or
98 PatchApplicationFailed when relevant.
maruel@chromium.org8a1396c2011-04-22 00:14:24 +000099
100 Args:
101 patches: patch.PatchSet object.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000102 """
103 raise NotImplementedError()
104
105 def commit(self, commit_message, user):
106 """Commits the patch upstream, while impersonating 'user'."""
107 raise NotImplementedError()
108
109
110class RawCheckout(CheckoutBase):
111 """Used to apply a patch locally without any intent to commit it.
112
113 To be used by the try server.
114 """
maruel@chromium.org51919772011-06-12 01:27:42 +0000115 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000116 """Stubbed out."""
117 pass
118
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000119 def apply_patch(self, patches):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000120 """Ignores svn properties."""
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000121 for p in patches:
122 try:
123 stdout = ''
124 filename = os.path.join(self.project_path, p.filename)
125 if p.is_delete:
126 os.remove(filename)
127 else:
128 dirname = os.path.dirname(p.filename)
129 full_dir = os.path.join(self.project_path, dirname)
130 if dirname and not os.path.isdir(full_dir):
131 os.makedirs(full_dir)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000132
133 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000134 if p.is_binary:
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000135 with open(filepath, 'wb') as f:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000136 f.write(p.get())
137 else:
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000138 if p.diff_hunks:
139 stdout = subprocess2.check_output(
140 ['patch', '-p%s' % p.patchlevel],
141 stdin=p.get(),
142 cwd=self.project_path)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000143 elif p.is_new and not os.path.exists(filepath):
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000144 # There is only a header. Just create the file.
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000145 open(filepath, 'w').close()
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000146 for post in (self.post_processors or []):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000147 post(self, p)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000148 except OSError, e:
149 raise PatchApplicationFailed(p.filename, '%s%s' % (stdout, e))
150 except subprocess.CalledProcessError, e:
151 raise PatchApplicationFailed(
152 p.filename, '%s%s' % (stdout, getattr(e, 'stdout', None)))
153
154 def commit(self, commit_message, user):
155 """Stubbed out."""
156 raise NotImplementedError('RawCheckout can\'t commit')
157
158
159class SvnConfig(object):
160 """Parses a svn configuration file."""
161 def __init__(self, svn_config_dir=None):
162 self.svn_config_dir = svn_config_dir
163 self.default = not bool(self.svn_config_dir)
164 if not self.svn_config_dir:
165 if sys.platform == 'win32':
166 self.svn_config_dir = os.path.join(os.environ['APPDATA'], 'Subversion')
167 else:
168 self.svn_config_dir = os.path.join(os.environ['HOME'], '.subversion')
169 svn_config_file = os.path.join(self.svn_config_dir, 'config')
170 parser = ConfigParser.SafeConfigParser()
171 if os.path.isfile(svn_config_file):
172 parser.read(svn_config_file)
173 else:
174 parser.add_section('auto-props')
175 self.auto_props = dict(parser.items('auto-props'))
176
177
178class SvnMixIn(object):
179 """MixIn class to add svn commands common to both svn and git-svn clients."""
180 # These members need to be set by the subclass.
181 commit_user = None
182 commit_pwd = None
183 svn_url = None
184 project_path = None
185 # Override at class level when necessary. If used, --non-interactive is
186 # implied.
187 svn_config = SvnConfig()
188 # Set to True when non-interactivity is necessary but a custom subversion
189 # configuration directory is not necessary.
190 non_interactive = False
191
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000192 def _add_svn_flags(self, args, non_interactive, credentials=True):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000193 args = ['svn'] + args
194 if not self.svn_config.default:
195 args.extend(['--config-dir', self.svn_config.svn_config_dir])
196 if not self.svn_config.default or self.non_interactive or non_interactive:
197 args.append('--non-interactive')
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000198 if credentials:
199 if self.commit_user:
200 args.extend(['--username', self.commit_user])
201 if self.commit_pwd:
202 args.extend(['--password', self.commit_pwd])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000203 return args
204
205 def _check_call_svn(self, args, **kwargs):
206 """Runs svn and throws an exception if the command failed."""
207 kwargs.setdefault('cwd', self.project_path)
208 kwargs.setdefault('stdout', self.VOID)
maruel@chromium.org0bcd1d32011-04-26 15:55:49 +0000209 return subprocess2.check_call_out(
210 self._add_svn_flags(args, False), **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000211
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000212 def _check_output_svn(self, args, credentials=True, **kwargs):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000213 """Runs svn and throws an exception if the command failed.
214
215 Returns the output.
216 """
217 kwargs.setdefault('cwd', self.project_path)
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000218 return subprocess2.check_output(
219 self._add_svn_flags(args, True, credentials), **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000220
221 @staticmethod
222 def _parse_svn_info(output, key):
223 """Returns value for key from svn info output.
224
225 Case insensitive.
226 """
227 values = {}
228 key = key.lower()
229 for line in output.splitlines(False):
230 if not line:
231 continue
232 k, v = line.split(':', 1)
233 k = k.strip().lower()
234 v = v.strip()
235 assert not k in values
236 values[k] = v
237 return values.get(key, None)
238
239
240class SvnCheckout(CheckoutBase, SvnMixIn):
241 """Manages a subversion checkout."""
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000242 def __init__(self, root_dir, project_name, commit_user, commit_pwd, svn_url,
243 post_processors=None):
244 super(SvnCheckout, self).__init__(root_dir, project_name, post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000245 self.commit_user = commit_user
246 self.commit_pwd = commit_pwd
247 self.svn_url = svn_url
248 assert bool(self.commit_user) >= bool(self.commit_pwd)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000249
maruel@chromium.org51919772011-06-12 01:27:42 +0000250 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000251 # Will checkout if the directory is not present.
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000252 assert self.svn_url
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000253 if not os.path.isdir(self.project_path):
254 logging.info('Checking out %s in %s' %
255 (self.project_name, self.project_path))
maruel@chromium.org51919772011-06-12 01:27:42 +0000256 return self._revert(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000257
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000258 def apply_patch(self, patches):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000259 for p in patches:
260 try:
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000261 # It is important to use credentials=False otherwise credentials could
262 # leak in the error message. Credentials are not necessary here for the
263 # following commands anyway.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000264 stdout = ''
265 if p.is_delete:
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000266 stdout += self._check_output_svn(
267 ['delete', p.filename, '--force'], credentials=False)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000268 else:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000269 # svn add while creating directories otherwise svn add on the
270 # contained files will silently fail.
271 # First, find the root directory that exists.
272 dirname = os.path.dirname(p.filename)
273 dirs_to_create = []
274 while (dirname and
275 not os.path.isdir(os.path.join(self.project_path, dirname))):
276 dirs_to_create.append(dirname)
277 dirname = os.path.dirname(dirname)
278 for dir_to_create in reversed(dirs_to_create):
279 os.mkdir(os.path.join(self.project_path, dir_to_create))
280 stdout += self._check_output_svn(
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000281 ['add', dir_to_create, '--force'], credentials=False)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000282
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000283 filepath = os.path.join(self.project_path, p.filename)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000284 if p.is_binary:
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000285 with open(filepath, 'wb') as f:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000286 f.write(p.get())
287 else:
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000288 if p.diff_hunks:
289 cmd = ['patch', '-p%s' % p.patchlevel, '--forward', '--force']
290 stdout += subprocess2.check_output(
291 cmd, stdin=p.get(), cwd=self.project_path)
maruel@chromium.org4869bcf2011-06-04 01:14:32 +0000292 elif p.is_new and not os.path.exists(filepath):
293 # There is only a header. Just create the file if it doesn't
294 # exist.
295 open(filepath, 'w').close()
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000296 if p.is_new:
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000297 stdout += self._check_output_svn(
298 ['add', p.filename, '--force'], credentials=False)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000299 for prop in p.svn_properties:
300 stdout += self._check_output_svn(
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000301 ['propset', prop[0], prop[1], p.filename], credentials=False)
302 for prop, values in self.svn_config.auto_props.iteritems():
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000303 if fnmatch.fnmatch(p.filename, prop):
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000304 for value in values.split(';'):
305 if '=' not in value:
306 params = [value, '*']
307 else:
308 params = value.split('=', 1)
309 stdout += self._check_output_svn(
310 ['propset'] + params + [p.filename], credentials=False)
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000311 for post in (self.post_processors or []):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000312 post(self, p)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000313 except OSError, e:
314 raise PatchApplicationFailed(p.filename, '%s%s' % (stdout, e))
315 except subprocess.CalledProcessError, e:
316 raise PatchApplicationFailed(
maruel@chromium.org9842a0c2011-05-30 20:41:54 +0000317 p.filename,
318 'While running %s;\n%s%s' % (
319 ' '.join(e.cmd), stdout, getattr(e, 'stdout', '')))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000320
321 def commit(self, commit_message, user):
322 logging.info('Committing patch for %s' % user)
323 assert self.commit_user
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000324 assert isinstance(commit_message, unicode)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000325 handle, commit_filename = tempfile.mkstemp(text=True)
326 try:
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000327 # Shouldn't assume default encoding is UTF-8. But really, if you are using
328 # anything else, you are living in another world.
329 os.write(handle, commit_message.encode('utf-8'))
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000330 os.close(handle)
331 # When committing, svn won't update the Revision metadata of the checkout,
332 # so if svn commit returns "Committed revision 3.", svn info will still
333 # return "Revision: 2". Since running svn update right after svn commit
334 # creates a race condition with other committers, this code _must_ parse
335 # the output of svn commit and use a regexp to grab the revision number.
336 # Note that "Committed revision N." is localized but subprocess2 forces
337 # LANGUAGE=en.
338 args = ['commit', '--file', commit_filename]
339 # realauthor is parsed by a server-side hook.
340 if user and user != self.commit_user:
341 args.extend(['--with-revprop', 'realauthor=%s' % user])
342 out = self._check_output_svn(args)
343 finally:
344 os.remove(commit_filename)
345 lines = filter(None, out.splitlines())
346 match = re.match(r'^Committed revision (\d+).$', lines[-1])
347 if not match:
348 raise PatchApplicationFailed(
349 None,
350 'Couldn\'t make sense out of svn commit message:\n' + out)
351 return int(match.group(1))
352
maruel@chromium.org51919772011-06-12 01:27:42 +0000353 def _revert(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000354 """Reverts local modifications or checks out if the directory is not
355 present. Use depot_tools's functionality to do this.
356 """
357 flags = ['--ignore-externals']
maruel@chromium.org51919772011-06-12 01:27:42 +0000358 if revision:
359 flags.extend(['--revision', str(revision)])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000360 if not os.path.isdir(self.project_path):
361 logging.info(
362 'Directory %s is not present, checking it out.' % self.project_path)
363 self._check_call_svn(
364 ['checkout', self.svn_url, self.project_path] + flags, cwd=None)
365 else:
366 scm.SVN.Revert(self.project_path)
367 # Revive files that were deleted in scm.SVN.Revert().
368 self._check_call_svn(['update', '--force'] + flags)
maruel@chromium.org51919772011-06-12 01:27:42 +0000369 return self._get_revision()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000370
maruel@chromium.org51919772011-06-12 01:27:42 +0000371 def _get_revision(self):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000372 out = self._check_output_svn(['info', '.'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000373 revision = int(self._parse_svn_info(out, 'revision'))
374 if revision != self._last_seen_revision:
375 logging.info('Updated to revision %d' % revision)
376 self._last_seen_revision = revision
377 return revision
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000378
379
380class GitCheckoutBase(CheckoutBase):
381 """Base class for git checkout. Not to be used as-is."""
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000382 def __init__(self, root_dir, project_name, remote_branch,
383 post_processors=None):
384 super(GitCheckoutBase, self).__init__(
385 root_dir, project_name, post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000386 # There is no reason to not hardcode it.
387 self.remote = 'origin'
388 self.remote_branch = remote_branch
389 self.working_branch = 'working_branch'
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000390
maruel@chromium.org51919772011-06-12 01:27:42 +0000391 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000392 """Resets the git repository in a clean state.
393
394 Checks it out if not present and deletes the working branch.
395 """
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000396 assert self.remote_branch
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000397 assert os.path.isdir(self.project_path)
398 self._check_call_git(['reset', '--hard', '--quiet'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000399 if revision:
400 try:
401 revision = self._check_output_git(['rev-parse', revision])
402 except subprocess.CalledProcessError:
403 self._check_call_git(
404 ['fetch', self.remote, self.remote_branch, '--quiet'])
405 revision = self._check_output_git(['rev-parse', revision])
406 self._check_call_git(['checkout', '--force', '--quiet', revision])
407 else:
408 branches, active = self._branches()
409 if active != 'master':
410 self._check_call_git(['checkout', '--force', '--quiet', 'master'])
411 self._check_call_git(['pull', self.remote, self.remote_branch, '--quiet'])
412 if self.working_branch in branches:
413 self._call_git(['branch', '-D', self.working_branch])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000414
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000415 def apply_patch(self, patches):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000416 """Applies a patch on 'working_branch' and switch to it.
417
418 Also commits the changes on the local branch.
419
420 Ignores svn properties and raise an exception on unexpected ones.
421 """
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000422 # It this throws, the checkout is corrupted. Maybe worth deleting it and
423 # trying again?
maruel@chromium.org3cdb7f32011-05-05 16:37:24 +0000424 if self.remote_branch:
425 self._check_call_git(
426 ['checkout', '-b', self.working_branch,
427 '%s/%s' % (self.remote, self.remote_branch), '--quiet'])
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000428 for p in patches:
429 try:
430 stdout = ''
431 if p.is_delete:
432 stdout += self._check_output_git(['rm', p.filename])
433 else:
434 dirname = os.path.dirname(p.filename)
435 full_dir = os.path.join(self.project_path, dirname)
436 if dirname and not os.path.isdir(full_dir):
437 os.makedirs(full_dir)
438 if p.is_binary:
439 with open(os.path.join(self.project_path, p.filename), 'wb') as f:
440 f.write(p.get())
441 stdout += self._check_output_git(['add', p.filename])
442 else:
maruel@chromium.org58fe6622011-06-03 20:59:27 +0000443 # No need to do anything special with p.is_new or if not
444 # p.diff_hunks. git apply manages all that already.
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000445 stdout += self._check_output_git(
446 ['apply', '--index', '-p%s' % p.patchlevel], stdin=p.get())
447 for prop in p.svn_properties:
448 # Ignore some known auto-props flags through .subversion/config,
449 # bails out on the other ones.
450 # TODO(maruel): Read ~/.subversion/config and detect the rules that
451 # applies here to figure out if the property will be correctly
452 # handled.
453 if not prop[0] in ('svn:eol-style', 'svn:executable'):
454 raise patch.UnsupportedPatchFormat(
455 p.filename,
456 'Cannot apply svn property %s to file %s.' % (
457 prop[0], p.filename))
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000458 for post in (self.post_processors or []):
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000459 post(self, p)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000460 except OSError, e:
461 raise PatchApplicationFailed(p.filename, '%s%s' % (stdout, e))
462 except subprocess.CalledProcessError, e:
463 raise PatchApplicationFailed(
464 p.filename, '%s%s' % (stdout, getattr(e, 'stdout', None)))
465 # Once all the patches are processed and added to the index, commit the
466 # index.
467 self._check_call_git(['commit', '-m', 'Committed patch'])
468 # TODO(maruel): Weirdly enough they don't match, need to investigate.
469 #found_files = self._check_output_git(
470 # ['diff', 'master', '--name-only']).splitlines(False)
471 #assert sorted(patches.filenames) == sorted(found_files), (
472 # sorted(out), sorted(found_files))
473
474 def commit(self, commit_message, user):
475 """Updates the commit message.
476
477 Subclass needs to dcommit or push.
478 """
maruel@chromium.org1bf50972011-05-05 19:57:21 +0000479 assert isinstance(commit_message, unicode)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000480 self._check_call_git(['commit', '--amend', '-m', commit_message])
481 return self._check_output_git(['rev-parse', 'HEAD']).strip()
482
483 def _check_call_git(self, args, **kwargs):
484 kwargs.setdefault('cwd', self.project_path)
485 kwargs.setdefault('stdout', self.VOID)
maruel@chromium.org0bcd1d32011-04-26 15:55:49 +0000486 return subprocess2.check_call_out(['git'] + args, **kwargs)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000487
488 def _call_git(self, args, **kwargs):
489 """Like check_call but doesn't throw on failure."""
490 kwargs.setdefault('cwd', self.project_path)
491 kwargs.setdefault('stdout', self.VOID)
492 return subprocess2.call(['git'] + args, **kwargs)
493
494 def _check_output_git(self, args, **kwargs):
495 kwargs.setdefault('cwd', self.project_path)
496 return subprocess2.check_output(['git'] + args, **kwargs)
497
498 def _branches(self):
499 """Returns the list of branches and the active one."""
500 out = self._check_output_git(['branch']).splitlines(False)
501 branches = [l[2:] for l in out]
502 active = None
503 for l in out:
504 if l.startswith('*'):
505 active = l[2:]
506 break
507 return branches, active
508
509
510class GitSvnCheckoutBase(GitCheckoutBase, SvnMixIn):
511 """Base class for git-svn checkout. Not to be used as-is."""
512 def __init__(self,
513 root_dir, project_name, remote_branch,
514 commit_user, commit_pwd,
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000515 svn_url, trunk, post_processors=None):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000516 """trunk is optional."""
517 super(GitSvnCheckoutBase, self).__init__(
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000518 root_dir, project_name + '.git', remote_branch, post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000519 self.commit_user = commit_user
520 self.commit_pwd = commit_pwd
521 # svn_url in this case is the root of the svn repository.
522 self.svn_url = svn_url
523 self.trunk = trunk
524 assert bool(self.commit_user) >= bool(self.commit_pwd)
525 assert self.svn_url
526 assert self.trunk
527 self._cache_svn_auth()
528
maruel@chromium.org51919772011-06-12 01:27:42 +0000529 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000530 """Resets the git repository in a clean state."""
531 self._check_call_git(['reset', '--hard', '--quiet'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000532 if revision:
533 try:
534 revision = self._check_output_git(
535 ['svn', 'find-rev', 'r%d' % revision])
536 except subprocess.CalledProcessError:
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000537 self._check_call_git(
maruel@chromium.org51919772011-06-12 01:27:42 +0000538 ['fetch', self.remote, self.remote_branch, '--quiet'])
539 revision = self._check_output_git(
540 ['svn', 'find-rev', 'r%d' % revision])
541 super(GitSvnCheckoutBase, self).prepare(revision)
542 else:
543 branches, active = self._branches()
544 if active != 'master':
545 if not 'master' in branches:
546 self._check_call_git(
547 ['checkout', '--quiet', '-b', 'master',
548 '%s/%s' % (self.remote, self.remote_branch)])
549 else:
550 self._check_call_git(['checkout', 'master', '--force', '--quiet'])
551 # git svn rebase --quiet --quiet doesn't work, use two steps to silence
552 # it.
553 self._check_call_git_svn(['fetch', '--quiet', '--quiet'])
554 self._check_call_git(
555 ['rebase', '--quiet', '--quiet',
556 '%s/%s' % (self.remote, self.remote_branch)])
557 if self.working_branch in branches:
558 self._call_git(['branch', '-D', self.working_branch])
559 return self._get_revision()
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000560
561 def _git_svn_info(self, key):
562 """Calls git svn info. This doesn't support nor need --config-dir."""
563 return self._parse_svn_info(self._check_output_git(['svn', 'info']), key)
564
565 def commit(self, commit_message, user):
566 """Commits a patch."""
567 logging.info('Committing patch for %s' % user)
568 # Fix the commit message and author. It returns the git hash, which we
569 # ignore unless it's None.
570 if not super(GitSvnCheckoutBase, self).commit(commit_message, user):
571 return None
572 # TODO(maruel): git-svn ignores --config-dir as of git-svn version 1.7.4 and
573 # doesn't support --with-revprop.
574 # Either learn perl and upstream or suck it.
575 kwargs = {}
576 if self.commit_pwd:
577 kwargs['stdin'] = self.commit_pwd + '\n'
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000578 kwargs['stderr'] = subprocess2.STDOUT
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000579 self._check_call_git_svn(
580 ['dcommit', '--rmdir', '--find-copies-harder',
581 '--username', self.commit_user],
582 **kwargs)
583 revision = int(self._git_svn_info('revision'))
584 return revision
585
586 def _cache_svn_auth(self):
587 """Caches the svn credentials. It is necessary since git-svn doesn't prompt
588 for it."""
589 if not self.commit_user or not self.commit_pwd:
590 return
591 # Use capture to lower noise in logs.
592 self._check_output_svn(['ls', self.svn_url], cwd=None)
593
594 def _check_call_git_svn(self, args, **kwargs):
595 """Handles svn authentication while calling git svn."""
596 args = ['svn'] + args
597 if not self.svn_config.default:
598 args.extend(['--config-dir', self.svn_config.svn_config_dir])
599 return self._check_call_git(args, **kwargs)
600
601 def _get_revision(self):
602 revision = int(self._git_svn_info('revision'))
603 if revision != self._last_seen_revision:
maruel@chromium.org51919772011-06-12 01:27:42 +0000604 logging.info('Updated to revision %d' % revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000605 self._last_seen_revision = revision
606 return revision
607
608
609class GitSvnPremadeCheckout(GitSvnCheckoutBase):
610 """Manages a git-svn clone made out from an initial git-svn seed.
611
612 This class is very similar to GitSvnCheckout but is faster to bootstrap
613 because it starts right off with an existing git-svn clone.
614 """
615 def __init__(self,
616 root_dir, project_name, remote_branch,
617 commit_user, commit_pwd,
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000618 svn_url, trunk, git_url, post_processors=None):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000619 super(GitSvnPremadeCheckout, self).__init__(
620 root_dir, project_name, remote_branch,
621 commit_user, commit_pwd,
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000622 svn_url, trunk, post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000623 self.git_url = git_url
624 assert self.git_url
625
maruel@chromium.org51919772011-06-12 01:27:42 +0000626 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000627 """Creates the initial checkout for the repo."""
628 if not os.path.isdir(self.project_path):
629 logging.info('Checking out %s in %s' %
630 (self.project_name, self.project_path))
631 assert self.remote == 'origin'
632 # self.project_path doesn't exist yet.
633 self._check_call_git(
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000634 ['clone', self.git_url, self.project_name, '--quiet'],
635 cwd=self.root_dir,
636 stderr=subprocess2.STDOUT)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000637 try:
638 configured_svn_url = self._check_output_git(
639 ['config', 'svn-remote.svn.url']).strip()
640 except subprocess.CalledProcessError:
641 configured_svn_url = ''
642
643 if configured_svn_url.strip() != self.svn_url:
644 self._check_call_git_svn(
645 ['init',
646 '--prefix', self.remote + '/',
647 '-T', self.trunk,
648 self.svn_url])
649 self._check_call_git_svn(['fetch'])
maruel@chromium.org51919772011-06-12 01:27:42 +0000650 return super(GitSvnPremadeCheckout, self).prepare(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000651
652
653class GitSvnCheckout(GitSvnCheckoutBase):
654 """Manages a git-svn clone.
655
656 Using git-svn hides some of the complexity of using a svn checkout.
657 """
658 def __init__(self,
659 root_dir, project_name,
660 commit_user, commit_pwd,
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000661 svn_url, trunk, post_processors=None):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000662 super(GitSvnCheckout, self).__init__(
663 root_dir, project_name, 'trunk',
664 commit_user, commit_pwd,
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000665 svn_url, trunk, post_processors)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000666
maruel@chromium.org51919772011-06-12 01:27:42 +0000667 def prepare(self, revision):
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000668 """Creates the initial checkout for the repo."""
maruel@chromium.org51919772011-06-12 01:27:42 +0000669 assert not revision, 'Implement revision if necessary'
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000670 if not os.path.isdir(self.project_path):
671 logging.info('Checking out %s in %s' %
672 (self.project_name, self.project_path))
673 # TODO: Create a shallow clone.
674 # self.project_path doesn't exist yet.
675 self._check_call_git_svn(
676 ['clone',
677 '--prefix', self.remote + '/',
678 '-T', self.trunk,
maruel@chromium.org8a1396c2011-04-22 00:14:24 +0000679 self.svn_url, self.project_path,
680 '--quiet'],
681 cwd=self.root_dir,
682 stderr=subprocess2.STDOUT)
maruel@chromium.org51919772011-06-12 01:27:42 +0000683 return super(GitSvnCheckout, self).prepare(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000684
685
686class ReadOnlyCheckout(object):
687 """Converts a checkout into a read-only one."""
688 def __init__(self, checkout):
689 self.checkout = checkout
690
maruel@chromium.org51919772011-06-12 01:27:42 +0000691 def prepare(self, revision):
692 return self.checkout.prepare(revision)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000693
694 def get_settings(self, key):
695 return self.checkout.get_settings(key)
696
maruel@chromium.org6ed8b502011-06-12 01:05:35 +0000697 def apply_patch(self, patches):
698 return self.checkout.apply_patch(patches)
maruel@chromium.orgdfaecd22011-04-21 00:33:31 +0000699
700 def commit(self, message, user): # pylint: disable=R0201
701 logging.info('Would have committed for %s with message: %s' % (
702 user, message))
703 return 'FAKE'
704
705 @property
706 def project_name(self):
707 return self.checkout.project_name
708
709 @property
710 def project_path(self):
711 return self.checkout.project_path