Call fixChangeLogPatch when generating patches from webkit-patch
[WebKit-https.git] / Tools / Scripts / webkitpy / common / checkout / scm / svn.py
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 from webkitpy.common.config.urls import svn_server_host, svn_server_realm
42
43 from .scm import AuthenticationError, SCM, commit_error_handler
44
45 _log = logging.getLogger(__name__)
46
47
48 # A mixin class that represents common functionality for SVN and Git-SVN.
49 class SVNRepository(object):
50     svn_server_host = svn_server_host
51     svn_server_realm = svn_server_realm
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):
185         for path in paths:
186             self._add_parent_directories(os.path.dirname(os.path.abspath(path)))
187         if self.svn_version() >= "1.7":
188             # For subversion client 1.7 and later, need to add '--parents' option to ensure intermediate directories
189             # are added; in addition, 1.7 returns an exit code of 1 from svn add if one or more of the requested
190             # adds are already under version control, including intermediate directories subject to addition
191             # due to --parents
192             svn_add_args = ['svn', 'add', '--parents'] + paths
193             exit_code = self.run(svn_add_args, return_exit_code=True)
194             if exit_code and exit_code != 1:
195                 raise ScriptError(script_args=svn_add_args, exit_code=exit_code)
196         else:
197             self._run_svn(["add"] + paths)
198
199     def _delete_parent_directories(self, path):
200         if not self.in_working_directory(path):
201             return
202         if set(os.listdir(path)) - self._svn_metadata_files:
203             return  # Directory has non-trivial files in it.
204         self.delete(path)
205
206     def delete_list(self, paths):
207         for path in paths:
208             abs_path = os.path.abspath(path)
209             parent, base = os.path.split(abs_path)
210             result = self._run_svn(["delete", "--force", base], cwd=parent)
211             self._delete_parent_directories(os.path.dirname(abs_path))
212         return result
213
214     def exists(self, path):
215         return not self._run_svn(["info", path], return_exit_code=True, decode_output=False)
216
217     def changed_files(self, git_commit=None):
218         status_command = [self.executable_name, "status"]
219         status_command.extend(self._patch_directories)
220         # ACDMR: Addded, Conflicted, Deleted, Modified or Replaced
221         return self.run_status_and_extract_filenames(status_command, self._status_regexp("ACDMR"))
222
223     def changed_files_for_revision(self, revision):
224         # As far as I can tell svn diff --summarize output looks just like svn status output.
225         # No file contents printed, thus utf-8 auto-decoding in self.run is fine.
226         status_command = [self.executable_name, "diff", "--summarize", "-c", revision]
227         return self.run_status_and_extract_filenames(status_command, self._status_regexp("ACDMR"))
228
229     def revisions_changing_file(self, path, limit=5):
230         revisions = []
231         # svn log will exit(1) (and thus self.run will raise) if the path does not exist.
232         log_command = ['log', '--quiet', '--limit=%s' % limit, path]
233         for line in self._run_svn(log_command, cwd=self.checkout_root).splitlines():
234             match = re.search('^r(?P<revision>\d+) ', line)
235             if not match:
236                 continue
237             revisions.append(int(match.group('revision')))
238         return revisions
239
240     def conflicted_files(self):
241         return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("C"))
242
243     def added_files(self):
244         return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("A"))
245
246     def deleted_files(self):
247         return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("D"))
248
249     @staticmethod
250     def supports_local_commits():
251         return False
252
253     def display_name(self):
254         return "svn"
255
256     def svn_revision(self, path):
257         return self.value_from_svn_info(path, 'Revision')
258
259     def timestamp_of_revision(self, path, revision):
260         # We use --xml to get timestamps like 2013-02-08T08:18:04.964409Z
261         repository_root = self.value_from_svn_info(self.checkout_root, 'Repository Root')
262         info_output = Executive().run_command([self.executable_name, 'log', '-r', revision, '--xml', repository_root], cwd=path).rstrip()
263         match = re.search(r"^<date>(?P<value>.+)</date>\r?$", info_output, re.MULTILINE)
264         return match.group('value')
265
266     # FIXME: This method should be on Checkout.
267     def create_patch(self, git_commit=None, changed_files=None, git_index=None):
268         """Returns a byte array (str()) representing the patch file.
269         Patch files are effectively binary since they may contain
270         files of multiple different encodings."""
271         if changed_files == []:
272             return ""
273         elif changed_files == None:
274             changed_files = []
275         script_path = self._filesystem.join(self.checkout_root, "Tools", "Scripts", "svn-create-patch")
276         return self.fix_changelog_patch(
277                 self.run([script_path, "--no-style"] + changed_files,
278                     cwd=self.checkout_root, return_stderr=False,
279                     decode_output=False))
280
281     def committer_email_for_revision(self, revision):
282         return self._run_svn(["propget", "svn:author", "--revprop", "-r", revision]).rstrip()
283
284     def contents_at_revision(self, path, revision):
285         """Returns a byte array (str()) containing the contents
286         of path @ revision in the repository."""
287         remote_path = "%s/%s" % (self._repository_url(), path)
288         return self._run_svn(["cat", "-r", revision, remote_path], decode_output=False)
289
290     def diff_for_revision(self, revision):
291         # FIXME: This should probably use cwd=self.checkout_root
292         return self._run_svn(['diff', '-c', revision])
293
294     def _bogus_dir_name(self):
295         rnd = ''.join(random.sample(string.ascii_letters, 5))
296         if sys.platform.startswith("win"):
297             parent_dir = tempfile.gettempdir()
298         else:
299             parent_dir = sys.path[0]  # tempdir is not secure.
300         return os.path.join(parent_dir, "temp_svn_config_" + rnd)
301
302     def _setup_bogus_dir(self, log):
303         self._bogus_dir = self._bogus_dir_name()
304         if not os.path.exists(self._bogus_dir):
305             os.mkdir(self._bogus_dir)
306             self._delete_bogus_dir = True
307         else:
308             self._delete_bogus_dir = False
309         if log:
310             log.debug('  Html: temp config dir: "%s".', self._bogus_dir)
311
312     def _teardown_bogus_dir(self, log):
313         if self._delete_bogus_dir:
314             shutil.rmtree(self._bogus_dir, True)
315             if log:
316                 log.debug('  Html: removed temp config dir: "%s".', self._bogus_dir)
317         self._bogus_dir = None
318
319     def diff_for_file(self, path, log=None):
320         self._setup_bogus_dir(log)
321         try:
322             args = ['diff']
323             if self._bogus_dir:
324                 args += ['--config-dir', self._bogus_dir]
325             args.append(path)
326             return self._run_svn(args, cwd=self.checkout_root)
327         finally:
328             self._teardown_bogus_dir(log)
329
330     def show_head(self, path):
331         return self._run_svn(['cat', '-r', 'BASE', path], decode_output=False)
332
333     def _repository_url(self):
334         return self.value_from_svn_info(self.checkout_root, 'URL')
335
336     def apply_reverse_diff(self, revision):
337         # '-c -revision' applies the inverse diff of 'revision'
338         svn_merge_args = ['merge', '--non-interactive', '-c', '-%s' % revision, self._repository_url()]
339         _log.warning("svn merge has been known to take more than 10 minutes to complete.  It is recommended you use git for rollouts.")
340         _log.debug("Running 'svn %s'" % " ".join(svn_merge_args))
341         # FIXME: Should this use cwd=self.checkout_root?
342         self._run_svn(svn_merge_args)
343
344     def revert_files(self, file_paths):
345         # FIXME: This should probably use cwd=self.checkout_root.
346         self._run_svn(['revert'] + file_paths)
347
348     def commit_with_message(self, message, username=None, password=None, git_commit=None, force_squash=False, changed_files=None):
349         # git-commit and force are not used by SVN.
350         svn_commit_args = ["commit"]
351
352         if not username and not self.has_authorization_for_realm(self.svn_server_realm):
353             raise AuthenticationError(self.svn_server_host)
354         if username:
355             svn_commit_args.extend(["--username", username])
356
357         svn_commit_args.extend(["-m", message])
358
359         if changed_files:
360             svn_commit_args.extend(changed_files)
361
362         return self._run_svn(svn_commit_args, cwd=self.checkout_root, error_handler=commit_error_handler)
363
364     def svn_commit_log(self, svn_revision):
365         svn_revision = self.strip_r_from_svn_revision(svn_revision)
366         return self._run_svn(['log', '--non-interactive', '--revision', svn_revision])
367
368     def last_svn_commit_log(self):
369         # BASE is the checkout revision, HEAD is the remote repository revision
370         # http://svnbook.red-bean.com/en/1.0/ch03s03.html
371         return self.svn_commit_log('BASE')
372
373     def svn_blame(self, path):
374         return self._run_svn(['blame', path])
375
376     def propset(self, pname, pvalue, path):
377         dir, base = os.path.split(path)
378         return self._run_svn(['pset', pname, pvalue, base], cwd=dir)
379
380     def propget(self, pname, path):
381         dir, base = os.path.split(path)
382         return self._run_svn(['pget', pname, base], cwd=dir).encode('utf-8').rstrip("\n")