1 # Copyright (c) 2009, 2010, 2011 Google Inc. All rights reserved. 2 # Copyright (c) 2009 Apple Inc. All rights reserved. 3 # 4 # Redistribution and use in source and binary forms, with or without 5 # modification, are permitted provided that the following conditions are 6 # met: 7 # 8 # * Redistributions of source code must retain the above copyright 9 # notice, this list of conditions and the following disclaimer. 10 # * Redistributions in binary form must reproduce the above 11 # copyright notice, this list of conditions and the following disclaimer 12 # in the documentation and/or other materials provided with the 13 # distribution. 14 # * Neither the name of Google Inc. nor the names of its 15 # contributors may be used to endorse or promote products derived from 16 # this software without specific prior written permission. 17 # 18 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 30 import logging 31 import os 32 import random 33 import re 34 import shutil 35 import string 36 import sys 37 import tempfile 38 39 from webkitpy.common.memoized import memoized 40 from webkitpy.common.system.executive import Executive, ScriptError 41 42 from .scm import AuthenticationError, SCM, commit_error_handler 43 44 _log = logging.getLogger(__name__) 45 46 47 # A mixin class that represents common functionality for SVN and Git-SVN. 48 class SVNRepository(object): 49 # FIXME: These belong in common.config.urls 50 svn_server_host = "svn.webkit.org" 51 svn_server_realm = "<http://svn.webkit.org:80> Mac OS Forge" 52 53 def has_authorization_for_realm(self, realm, home_directory=os.getenv("HOME")): 54 # If we are working on a file:// repository realm will be None 55 if realm is None: 56 return True 57 # ignore false positives for methods implemented in the mixee class. pylint: disable=E1101 58 # Assumes find and grep are installed. 59 if not os.path.isdir(os.path.join(home_directory, ".subversion")): 60 return False 61 find_args = ["find", ".subversion", "-type", "f", "-exec", "grep", "-q", realm, "{}", ";", "-print"] 62 find_output = self.run(find_args, cwd=home_directory, error_handler=Executive.ignore_error).rstrip() 63 if not find_output or not os.path.isfile(os.path.join(home_directory, find_output)): 64 return False 65 # Subversion either stores the password in the credential file, indicated by the presence of the key "password", 66 # or uses the system password store (e.g. Keychain on Mac OS X) as indicated by the presence of the key "passtype". 67 # We assume that these keys will not coincide with the actual credential data (e.g. that a person's username 68 # isn't "password") so that we can use grep. 69 if self.run(["grep", "password", find_output], cwd=home_directory, return_exit_code=True) == 0: 70 return True 71 return self.run(["grep", "passtype", find_output], cwd=home_directory, return_exit_code=True) == 0 72 73 74 class SVN(SCM, SVNRepository): 75 76 executable_name = "svn" 77 78 _svn_metadata_files = frozenset(['.svn', '_svn']) 79 80 def __init__(self, cwd, patch_directories, **kwargs): 81 SCM.__init__(self, cwd, **kwargs) 82 self._bogus_dir = None 83 if patch_directories == []: 84 raise Exception(message='Empty list of patch directories passed to SCM.__init__') 85 elif patch_directories == None: 86 self._patch_directories = [self._filesystem.relpath(cwd, self.checkout_root)] 87 else: 88 self._patch_directories = patch_directories 89 90 @classmethod 91 def in_working_directory(cls, path, executive=None): 92 if os.path.isdir(os.path.join(path, '.svn')): 93 # This is a fast shortcut for svn info that is usually correct for SVN < 1.7, 94 # but doesn't work for SVN >= 1.7. 95 return True 96 97 executive = executive or Executive() 98 svn_info_args = [cls.executable_name, 'info'] 99 exit_code = executive.run_command(svn_info_args, cwd=path, return_exit_code=True) 100 return (exit_code == 0) 101 102 def find_uuid(self, path): 103 if not self.in_working_directory(path): 104 return None 105 return self.value_from_svn_info(path, 'Repository UUID') 106 107 @classmethod 108 def value_from_svn_info(cls, path, field_name): 109 svn_info_args = [cls.executable_name, 'info'] 110 # FIXME: This method should use a passed in executive or be made an instance method and use self._executive. 111 info_output = Executive().run_command(svn_info_args, cwd=path).rstrip() 112 match = re.search("^%s: (?P<value>.+)$" % field_name, info_output, re.MULTILINE) 113 if not match: 114 raise ScriptError(script_args=svn_info_args, message='svn info did not contain a %s.' % field_name) 115 return match.group('value').rstrip('\r') 116 117 def find_checkout_root(self, path): 118 uuid = self.find_uuid(path) 119 # If |path| is not in a working directory, we're supposed to return |path|. 120 if not uuid: 121 return path 122 # Search up the directory hierarchy until we find a different UUID. 123 last_path = None 124 while True: 125 if uuid != self.find_uuid(path): 126 return last_path 127 last_path = path 128 (path, last_component) = self._filesystem.split(path) 129 if last_path == path: 130 return None 131 132 @staticmethod 133 def commit_success_regexp(): 134 return "^Committed revision (?P<svn_revision>\d+)\.$" 135 136 def _run_svn(self, args, **kwargs): 137 return self.run([self.executable_name] + args, **kwargs) 138 139 @memoized 140 def svn_version(self): 141 return self._run_svn(['--version', '--quiet']) 142 143 def has_working_directory_changes(self): 144 # FIXME: What about files which are not committed yet? 145 return self._run_svn(["diff"], cwd=self.checkout_root, decode_output=False) != "" 146 147 def discard_working_directory_changes(self): 148 # Make sure there are no locks lying around from a previously aborted svn invocation. 149 # This is slightly dangerous, as it's possible the user is running another svn process 150 # on this checkout at the same time. However, it's much more likely that we're running 151 # under windows and svn just sucks (or the user interrupted svn and it failed to clean up). 152 self._run_svn(["cleanup"], cwd=self.checkout_root) 153 154 # svn revert -R is not as awesome as git reset --hard. 155 # It will leave added files around, causing later svn update 156 # calls to fail on the bots. We make this mirror git reset --hard 157 # by deleting any added files as well. 158 added_files = reversed(sorted(self.added_files())) 159 # added_files() returns directories for SVN, we walk the files in reverse path 160 # length order so that we remove files before we try to remove the directories. 161 self._run_svn(["revert", "-R", "."], cwd=self.checkout_root) 162 for path in added_files: 163 # This is robust against cwd != self.checkout_root 164 absolute_path = self.absolute_path(path) 165 # Completely lame that there is no easy way to remove both types with one call. 166 if os.path.isdir(path): 167 os.rmdir(absolute_path) 168 else: 169 os.remove(absolute_path) 170 171 def status_command(self): 172 return [self.executable_name, 'status'] 173 174 def _status_regexp(self, expected_types): 175 field_count = 6 if self.svn_version() > "1.6" else 5 176 return "^(?P<status>[%s]).{%s} (?P<filename>.+)$" % (expected_types, field_count) 177 178 def _add_parent_directories(self, path): 179 """Does 'svn add' to the path and its parents.""" 180 if self.in_working_directory(path): 181 return 182 self.add(path) 183 184 def add_list(self, paths, return_exit_code=False): 185 for path in paths: 186 self._add_parent_directories(os.path.dirname(os.path.abspath(path))) 187 return self._run_svn(["add"] + paths, return_exit_code=return_exit_code) 188 189 def _delete_parent_directories(self, path): 190 if not self.in_working_directory(path): 191 return 192 if set(os.listdir(path)) - self._svn_metadata_files: 193 return # Directory has non-trivial files in it. 194 self.delete(path) 195 196 def delete_list(self, paths): 197 for path in paths: 198 abs_path = os.path.abspath(path) 199 parent, base = os.path.split(abs_path) 200 result = self._run_svn(["delete", "--force", base], cwd=parent) 201 self._delete_parent_directories(os.path.dirname(abs_path)) 202 return result 203 204 def move(self, origin, destination): 205 return self._run_svn(["mv", "--force", origin, destination], return_exit_code=True) 206 207 def exists(self, path): 208 return not self._run_svn(["info", path], return_exit_code=True, decode_output=False) 209 210 def changed_files(self, git_commit=None): 211 status_command = [self.executable_name, "status"] 212 status_command.extend(self._patch_directories) 213 # ACDMR: Addded, Conflicted, Deleted, Modified or Replaced 214 return self.run_status_and_extract_filenames(status_command, self._status_regexp("ACDMR")) 215 216 def changed_files_for_revision(self, revision): 217 # As far as I can tell svn diff --summarize output looks just like svn status output. 218 # No file contents printed, thus utf-8 auto-decoding in self.run is fine. 219 status_command = [self.executable_name, "diff", "--summarize", "-c", revision] 220 return self.run_status_and_extract_filenames(status_command, self._status_regexp("ACDMR")) 221 222 def revisions_changing_file(self, path, limit=5): 223 revisions = [] 224 # svn log will exit(1) (and thus self.run will raise) if the path does not exist. 225 log_command = ['log', '--quiet', '--limit=%s' % limit, path] 226 for line in self._run_svn(log_command, cwd=self.checkout_root).splitlines(): 227 match = re.search('^r(?P<revision>\d+) ', line) 228 if not match: 229 continue 230 revisions.append(int(match.group('revision'))) 231 return revisions 232 233 def conflicted_files(self): 234 return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("C")) 235 236 def added_files(self): 237 return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("A")) 238 239 def deleted_files(self): 240 return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("D")) 241 242 @staticmethod 243 def supports_local_commits(): 244 return False 245 246 def display_name(self): 247 return "svn" 248 249 def svn_revision(self, path): 250 return self.value_from_svn_info(path, 'Revision') 251 252 def timestamp_of_revision(self, path, revision): 253 # We use --xml to get timestamps like 2013-02-08T08:18:04.964409Z 254 repository_root = self.value_from_svn_info(self.checkout_root, 'Repository Root') 255 info_output = Executive().run_command([self.executable_name, 'log', '-r', revision, '--xml', repository_root], cwd=path).rstrip() 256 match = re.search(r"^<date>(?P<value>.+)</date>\r?$", info_output, re.MULTILINE) 257 return match.group('value') 258 259 # FIXME: This method should be on Checkout. 260 def create_patch(self, git_commit=None, changed_files=None): 261 """Returns a byte array (str()) representing the patch file. 262 Patch files are effectively binary since they may contain 263 files of multiple different encodings.""" 264 if changed_files == []: 265 return "" 266 elif changed_files == None: 267 changed_files = [] 268 return self.run([self.script_path("svn-create-patch")] + changed_files, 269 cwd=self.checkout_root, return_stderr=False, 270 decode_output=False) 271 272 def committer_email_for_revision(self, revision): 273 return self._run_svn(["propget", "svn:author", "--revprop", "-r", revision]).rstrip() 274 275 def contents_at_revision(self, path, revision): 276 """Returns a byte array (str()) containing the contents 277 of path @ revision in the repository.""" 278 remote_path = "%s/%s" % (self._repository_url(), path) 279 return self._run_svn(["cat", "-r", revision, remote_path], decode_output=False) 280 281 def diff_for_revision(self, revision): 282 # FIXME: This should probably use cwd=self.checkout_root 283 return self._run_svn(['diff', '-c', revision]) 284 285 def _bogus_dir_name(self): 286 rnd = ''.join(random.sample(string.ascii_letters, 5)) 287 if sys.platform.startswith("win"): 288 parent_dir = tempfile.gettempdir() 289 else: 290 parent_dir = sys.path[0] # tempdir is not secure. 291 return os.path.join(parent_dir, "temp_svn_config_" + rnd) 292 293 def _setup_bogus_dir(self, log): 294 self._bogus_dir = self._bogus_dir_name() 295 if not os.path.exists(self._bogus_dir): 296 os.mkdir(self._bogus_dir) 297 self._delete_bogus_dir = True 298 else: 299 self._delete_bogus_dir = False 300 if log: 301 log.debug(' Html: temp config dir: "%s".', self._bogus_dir) 302 303 def _teardown_bogus_dir(self, log): 304 if self._delete_bogus_dir: 305 shutil.rmtree(self._bogus_dir, True) 306 if log: 307 log.debug(' Html: removed temp config dir: "%s".', self._bogus_dir) 308 self._bogus_dir = None 309 310 def diff_for_file(self, path, log=None): 311 self._setup_bogus_dir(log) 312 try: 313 args = ['diff'] 314 if self._bogus_dir: 315 args += ['--config-dir', self._bogus_dir] 316 args.append(path) 317 return self._run_svn(args, cwd=self.checkout_root) 318 finally: 319 self._teardown_bogus_dir(log) 320 321 def show_head(self, path): 322 return self._run_svn(['cat', '-r', 'BASE', path], decode_output=False) 323 324 def _repository_url(self): 325 return self.value_from_svn_info(self.checkout_root, 'URL') 326 327 def apply_reverse_diff(self, revision): 328 # '-c -revision' applies the inverse diff of 'revision' 329 svn_merge_args = ['merge', '--non-interactive', '-c', '-%s' % revision, self._repository_url()] 330 _log.warning("svn merge has been known to take more than 10 minutes to complete. It is recommended you use git for rollouts.") 331 _log.debug("Running 'svn %s'" % " ".join(svn_merge_args)) 332 # FIXME: Should this use cwd=self.checkout_root? 333 self._run_svn(svn_merge_args) 334 335 def revert_files(self, file_paths): 336 # FIXME: This should probably use cwd=self.checkout_root. 337 self._run_svn(['revert'] + file_paths) 338 339 def commit_with_message(self, message, username=None, password=None, git_commit=None, force_squash=False, changed_files=None): 340 # git-commit and force are not used by SVN. 341 svn_commit_args = ["commit"] 342 343 if not username and not self.has_authorization_for_realm(self.svn_server_realm): 344 raise AuthenticationError(self.svn_server_host) 345 if username: 346 svn_commit_args.extend(["--username", username]) 347 348 svn_commit_args.extend(["-m", message]) 349 350 if changed_files: 351 svn_commit_args.extend(changed_files) 352 353 return self._run_svn(svn_commit_args, cwd=self.checkout_root, error_handler=commit_error_handler) 354 355 def svn_commit_log(self, svn_revision): 356 svn_revision = self.strip_r_from_svn_revision(svn_revision) 357 return self._run_svn(['log', '--non-interactive', '--revision', svn_revision]) 358 359 def last_svn_commit_log(self): 360 # BASE is the checkout revision, HEAD is the remote repository revision 361 # http://svnbook.red-bean.com/en/1.0/ch03s03.html 362 return self.svn_commit_log('BASE') 363 364 def blame(self, path): 365 return self._run_svn(['blame', path]) 366 367 def svn_blame(self, path): 368 return self._run_svn(['blame', path]) 369 370 def propset(self, pname, pvalue, path): 371 dir, base = os.path.split(path) 372 return self._run_svn(['pset', pname, pvalue, base], cwd=dir) 373 374 def propget(self, pname, path): 375 dir, base = os.path.split(path) 376 return self._run_svn(['pget', pname, base], cwd=dir).encode('utf-8').rstrip("\n") 377