• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright (c) 2009, 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# Python module for interacting with an SCM system (like SVN or Git)
31
32import os
33import re
34import subprocess
35
36# Import WebKit-specific modules.
37from webkitpy.changelogs import ChangeLog
38from webkitpy.executive import Executive, run_command, ScriptError
39from webkitpy.webkit_logging import error, log
40
41def detect_scm_system(path):
42    if SVN.in_working_directory(path):
43        return SVN(cwd=path)
44
45    if Git.in_working_directory(path):
46        return Git(cwd=path)
47
48    return None
49
50def first_non_empty_line_after_index(lines, index=0):
51    first_non_empty_line = index
52    for line in lines[index:]:
53        if re.match("^\s*$", line):
54            first_non_empty_line += 1
55        else:
56            break
57    return first_non_empty_line
58
59
60class CommitMessage:
61    def __init__(self, message):
62        self.message_lines = message[first_non_empty_line_after_index(message, 0):]
63
64    def body(self, lstrip=False):
65        lines = self.message_lines[first_non_empty_line_after_index(self.message_lines, 1):]
66        if lstrip:
67            lines = [line.lstrip() for line in lines]
68        return "\n".join(lines) + "\n"
69
70    def description(self, lstrip=False, strip_url=False):
71        line = self.message_lines[0]
72        if lstrip:
73            line = line.lstrip()
74        if strip_url:
75            line = re.sub("^(\s*)<.+> ", "\1", line)
76        return line
77
78    def message(self):
79        return "\n".join(self.message_lines) + "\n"
80
81
82class CheckoutNeedsUpdate(ScriptError):
83    def __init__(self, script_args, exit_code, output, cwd):
84        ScriptError.__init__(self, script_args=script_args, exit_code=exit_code, output=output, cwd=cwd)
85
86
87def commit_error_handler(error):
88    if re.search("resource out of date", error.output):
89        raise CheckoutNeedsUpdate(script_args=error.script_args, exit_code=error.exit_code, output=error.output, cwd=error.cwd)
90    Executive.default_error_handler(error)
91
92
93class SCM:
94    def __init__(self, cwd, dryrun=False):
95        self.cwd = cwd
96        self.checkout_root = self.find_checkout_root(self.cwd)
97        self.dryrun = dryrun
98
99    def scripts_directory(self):
100        return os.path.join(self.checkout_root, "WebKitTools", "Scripts")
101
102    def script_path(self, script_name):
103        return os.path.join(self.scripts_directory(), script_name)
104
105    def ensure_clean_working_directory(self, force_clean):
106        if not force_clean and not self.working_directory_is_clean():
107            print run_command(self.status_command(), error_handler=Executive.ignore_error)
108            raise ScriptError(message="Working directory has modifications, pass --force-clean or --no-clean to continue.")
109
110        log("Cleaning working directory")
111        self.clean_working_directory()
112
113    def ensure_no_local_commits(self, force):
114        if not self.supports_local_commits():
115            return
116        commits = self.local_commits()
117        if not len(commits):
118            return
119        if not force:
120            error("Working directory has local commits, pass --force-clean to continue.")
121        self.discard_local_commits()
122
123    def apply_patch(self, patch, force=False):
124        # It's possible that the patch was not made from the root directory.
125        # We should detect and handle that case.
126        # FIXME: scm.py should not deal with fetching Attachment data.  Attachment should just have a .data() accessor.
127        curl_process = subprocess.Popen(['curl', '--location', '--silent', '--show-error', patch.url()], stdout=subprocess.PIPE)
128        args = [self.script_path('svn-apply')]
129        if patch.reviewer():
130            args += ['--reviewer', patch.reviewer().full_name]
131        if force:
132            args.append('--force')
133
134        run_command(args, input=curl_process.stdout)
135
136    def run_status_and_extract_filenames(self, status_command, status_regexp):
137        filenames = []
138        for line in run_command(status_command).splitlines():
139            match = re.search(status_regexp, line)
140            if not match:
141                continue
142            # status = match.group('status')
143            filename = match.group('filename')
144            filenames.append(filename)
145        return filenames
146
147    def strip_r_from_svn_revision(self, svn_revision):
148        match = re.match("^r(?P<svn_revision>\d+)", svn_revision)
149        if (match):
150            return match.group('svn_revision')
151        return svn_revision
152
153    def svn_revision_from_commit_text(self, commit_text):
154        match = re.search(self.commit_success_regexp(), commit_text, re.MULTILINE)
155        return match.group('svn_revision')
156
157    # ChangeLog-specific code doesn't really belong in scm.py, but this function is very useful.
158    def modified_changelogs(self):
159        changelog_paths = []
160        paths = self.changed_files()
161        for path in paths:
162            if os.path.basename(path) == "ChangeLog":
163                changelog_paths.append(path)
164        return changelog_paths
165
166    # FIXME: Requires unit test
167    # FIXME: commit_message_for_this_commit and modified_changelogs don't
168    #        really belong here.  We should have a separate module for
169    #        handling ChangeLogs.
170    def commit_message_for_this_commit(self):
171        changelog_paths = self.modified_changelogs()
172        if not len(changelog_paths):
173            raise ScriptError(message="Found no modified ChangeLogs, cannot create a commit message.\n"
174                              "All changes require a ChangeLog.  See:\n"
175                              "http://webkit.org/coding/contributing.html")
176
177        changelog_messages = []
178        for changelog_path in changelog_paths:
179            log("Parsing ChangeLog: %s" % changelog_path)
180            changelog_entry = ChangeLog(changelog_path).latest_entry()
181            if not changelog_entry:
182                raise ScriptError(message="Failed to parse ChangeLog: " + os.path.abspath(changelog_path))
183            changelog_messages.append(changelog_entry)
184
185        # FIXME: We should sort and label the ChangeLog messages like commit-log-editor does.
186        return CommitMessage("".join(changelog_messages).splitlines())
187
188    @staticmethod
189    def in_working_directory(path):
190        raise NotImplementedError, "subclasses must implement"
191
192    @staticmethod
193    def find_checkout_root(path):
194        raise NotImplementedError, "subclasses must implement"
195
196    @staticmethod
197    def commit_success_regexp():
198        raise NotImplementedError, "subclasses must implement"
199
200    def working_directory_is_clean(self):
201        raise NotImplementedError, "subclasses must implement"
202
203    def clean_working_directory(self):
204        raise NotImplementedError, "subclasses must implement"
205
206    def status_command(self):
207        raise NotImplementedError, "subclasses must implement"
208
209    def changed_files(self):
210        raise NotImplementedError, "subclasses must implement"
211
212    def display_name(self):
213        raise NotImplementedError, "subclasses must implement"
214
215    def create_patch(self):
216        raise NotImplementedError, "subclasses must implement"
217
218    def diff_for_revision(self, revision):
219        raise NotImplementedError, "subclasses must implement"
220
221    def apply_reverse_diff(self, revision):
222        raise NotImplementedError, "subclasses must implement"
223
224    def revert_files(self, file_paths):
225        raise NotImplementedError, "subclasses must implement"
226
227    def commit_with_message(self, message):
228        raise NotImplementedError, "subclasses must implement"
229
230    def svn_commit_log(self, svn_revision):
231        raise NotImplementedError, "subclasses must implement"
232
233    def last_svn_commit_log(self):
234        raise NotImplementedError, "subclasses must implement"
235
236    # Subclasses must indicate if they support local commits,
237    # but the SCM baseclass will only call local_commits methods when this is true.
238    @staticmethod
239    def supports_local_commits():
240        raise NotImplementedError, "subclasses must implement"
241
242    def create_patch_from_local_commit(self, commit_id):
243        error("Your source control manager does not support creating a patch from a local commit.")
244
245    def create_patch_since_local_commit(self, commit_id):
246        error("Your source control manager does not support creating a patch from a local commit.")
247
248    def commit_locally_with_message(self, message):
249        error("Your source control manager does not support local commits.")
250
251    def discard_local_commits(self):
252        pass
253
254    def local_commits(self):
255        return []
256
257
258class SVN(SCM):
259    def __init__(self, cwd, dryrun=False):
260        SCM.__init__(self, cwd, dryrun)
261        self.cached_version = None
262
263    @staticmethod
264    def in_working_directory(path):
265        return os.path.isdir(os.path.join(path, '.svn'))
266
267    @classmethod
268    def find_uuid(cls, path):
269        if not cls.in_working_directory(path):
270            return None
271        return cls.value_from_svn_info(path, 'Repository UUID')
272
273    @classmethod
274    def value_from_svn_info(cls, path, field_name):
275        svn_info_args = ['svn', 'info', path]
276        info_output = run_command(svn_info_args).rstrip()
277        match = re.search("^%s: (?P<value>.+)$" % field_name, info_output, re.MULTILINE)
278        if not match:
279            raise ScriptError(script_args=svn_info_args, message='svn info did not contain a %s.' % field_name)
280        return match.group('value')
281
282    @staticmethod
283    def find_checkout_root(path):
284        uuid = SVN.find_uuid(path)
285        # If |path| is not in a working directory, we're supposed to return |path|.
286        if not uuid:
287            return path
288        # Search up the directory hierarchy until we find a different UUID.
289        last_path = None
290        while True:
291            if uuid != SVN.find_uuid(path):
292                return last_path
293            last_path = path
294            (path, last_component) = os.path.split(path)
295            if last_path == path:
296                return None
297
298    @staticmethod
299    def commit_success_regexp():
300        return "^Committed revision (?P<svn_revision>\d+)\.$"
301
302    def svn_version(self):
303        if not self.cached_version:
304            self.cached_version = run_command(['svn', '--version', '--quiet'])
305
306        return self.cached_version
307
308    def working_directory_is_clean(self):
309        return run_command(['svn', 'diff']) == ""
310
311    def clean_working_directory(self):
312        run_command(['svn', 'revert', '-R', '.'])
313
314    def status_command(self):
315        return ['svn', 'status']
316
317    def changed_files(self):
318        if self.svn_version() > "1.6":
319            status_regexp = "^(?P<status>[ACDMR]).{6} (?P<filename>.+)$"
320        else:
321            status_regexp = "^(?P<status>[ACDMR]).{5} (?P<filename>.+)$"
322        return self.run_status_and_extract_filenames(self.status_command(), status_regexp)
323
324    @staticmethod
325    def supports_local_commits():
326        return False
327
328    def display_name(self):
329        return "svn"
330
331    def create_patch(self):
332        return run_command(self.script_path("svn-create-patch"), cwd=self.checkout_root, return_stderr=False)
333
334    def diff_for_revision(self, revision):
335        return run_command(['svn', 'diff', '-c', str(revision)])
336
337    def _repository_url(self):
338        return self.value_from_svn_info(self.checkout_root, 'URL')
339
340    def apply_reverse_diff(self, revision):
341        # '-c -revision' applies the inverse diff of 'revision'
342        svn_merge_args = ['svn', 'merge', '--non-interactive', '-c', '-%s' % revision, self._repository_url()]
343        log("WARNING: svn merge has been known to take more than 10 minutes to complete.  It is recommended you use git for rollouts.")
344        log("Running '%s'" % " ".join(svn_merge_args))
345        run_command(svn_merge_args)
346
347    def revert_files(self, file_paths):
348        run_command(['svn', 'revert'] + file_paths)
349
350    def commit_with_message(self, message):
351        if self.dryrun:
352            # Return a string which looks like a commit so that things which parse this output will succeed.
353            return "Dry run, no commit.\nCommitted revision 0."
354        return run_command(['svn', 'commit', '-m', message], error_handler=commit_error_handler)
355
356    def svn_commit_log(self, svn_revision):
357        svn_revision = self.strip_r_from_svn_revision(str(svn_revision))
358        return run_command(['svn', 'log', '--non-interactive', '--revision', svn_revision]);
359
360    def last_svn_commit_log(self):
361        # BASE is the checkout revision, HEAD is the remote repository revision
362        # http://svnbook.red-bean.com/en/1.0/ch03s03.html
363        return self.svn_commit_log('BASE')
364
365# All git-specific logic should go here.
366class Git(SCM):
367    def __init__(self, cwd, dryrun=False):
368        SCM.__init__(self, cwd, dryrun)
369
370    @classmethod
371    def in_working_directory(cls, path):
372        return run_command(['git', 'rev-parse', '--is-inside-work-tree'], cwd=path, error_handler=Executive.ignore_error).rstrip() == "true"
373
374    @classmethod
375    def find_checkout_root(cls, path):
376        # "git rev-parse --show-cdup" would be another way to get to the root
377        (checkout_root, dot_git) = os.path.split(run_command(['git', 'rev-parse', '--git-dir'], cwd=path))
378        # If we were using 2.6 # checkout_root = os.path.relpath(checkout_root, path)
379        if not os.path.isabs(checkout_root): # Sometimes git returns relative paths
380            checkout_root = os.path.join(path, checkout_root)
381        return checkout_root
382
383    @staticmethod
384    def commit_success_regexp():
385        return "^Committed r(?P<svn_revision>\d+)$"
386
387
388    def discard_local_commits(self):
389        run_command(['git', 'reset', '--hard', 'trunk'])
390
391    def local_commits(self):
392        return run_command(['git', 'log', '--pretty=oneline', 'HEAD...trunk']).splitlines()
393
394    def rebase_in_progress(self):
395        return os.path.exists(os.path.join(self.checkout_root, '.git/rebase-apply'))
396
397    def working_directory_is_clean(self):
398        return run_command(['git', 'diff-index', 'HEAD']) == ""
399
400    def clean_working_directory(self):
401        # Could run git clean here too, but that wouldn't match working_directory_is_clean
402        run_command(['git', 'reset', '--hard', 'HEAD'])
403        # Aborting rebase even though this does not match working_directory_is_clean
404        if self.rebase_in_progress():
405            run_command(['git', 'rebase', '--abort'])
406
407    def status_command(self):
408        return ['git', 'status']
409
410    def changed_files(self):
411        status_command = ['git', 'diff', '-r', '--name-status', '-C', '-M', 'HEAD']
412        status_regexp = '^(?P<status>[ADM])\t(?P<filename>.+)$'
413        return self.run_status_and_extract_filenames(status_command, status_regexp)
414
415    @staticmethod
416    def supports_local_commits():
417        return True
418
419    def display_name(self):
420        return "git"
421
422    def create_patch(self):
423        return run_command(['git', 'diff', '--binary', 'HEAD'])
424
425    @classmethod
426    def git_commit_from_svn_revision(cls, revision):
427        # git svn find-rev always exits 0, even when the revision is not found.
428        return run_command(['git', 'svn', 'find-rev', 'r%s' % revision]).rstrip()
429
430    def diff_for_revision(self, revision):
431        git_commit = self.git_commit_from_svn_revision(revision)
432        return self.create_patch_from_local_commit(git_commit)
433
434    def apply_reverse_diff(self, revision):
435        # Assume the revision is an svn revision.
436        git_commit = self.git_commit_from_svn_revision(revision)
437        if not git_commit:
438            raise ScriptError(message='Failed to find git commit for revision %s, git svn log output: "%s"' % (revision, git_commit))
439
440        # I think this will always fail due to ChangeLogs.
441        # FIXME: We need to detec specific failure conditions and handle them.
442        run_command(['git', 'revert', '--no-commit', git_commit], error_handler=Executive.ignore_error)
443
444        # Fix any ChangeLogs if necessary.
445        changelog_paths = self.modified_changelogs()
446        if len(changelog_paths):
447            run_command([self.script_path('resolve-ChangeLogs')] + changelog_paths)
448
449    def revert_files(self, file_paths):
450        run_command(['git', 'checkout', 'HEAD'] + file_paths)
451
452    def commit_with_message(self, message):
453        self.commit_locally_with_message(message)
454        return self.push_local_commits_to_server()
455
456    def svn_commit_log(self, svn_revision):
457        svn_revision = self.strip_r_from_svn_revision(svn_revision)
458        return run_command(['git', 'svn', 'log', '-r', svn_revision])
459
460    def last_svn_commit_log(self):
461        return run_command(['git', 'svn', 'log', '--limit=1'])
462
463    # Git-specific methods:
464
465    def create_patch_from_local_commit(self, commit_id):
466        return run_command(['git', 'diff', '--binary', commit_id + "^.." + commit_id])
467
468    def create_patch_since_local_commit(self, commit_id):
469        return run_command(['git', 'diff', '--binary', commit_id])
470
471    def commit_locally_with_message(self, message):
472        run_command(['git', 'commit', '--all', '-F', '-'], input=message)
473
474    def push_local_commits_to_server(self):
475        if self.dryrun:
476            # Return a string which looks like a commit so that things which parse this output will succeed.
477            return "Dry run, no remote commit.\nCommitted r0"
478        return run_command(['git', 'svn', 'dcommit'], error_handler=commit_error_handler)
479
480    # This function supports the following argument formats:
481    # no args : rev-list trunk..HEAD
482    # A..B    : rev-list A..B
483    # A...B   : error!
484    # A B     : [A, B]  (different from git diff, which would use "rev-list A..B")
485    def commit_ids_from_commitish_arguments(self, args):
486        if not len(args):
487            # FIXME: trunk is not always the remote branch name, need a way to detect the name.
488            args.append('trunk..HEAD')
489
490        commit_ids = []
491        for commitish in args:
492            if '...' in commitish:
493                raise ScriptError(message="'...' is not supported (found in '%s'). Did you mean '..'?" % commitish)
494            elif '..' in commitish:
495                commit_ids += reversed(run_command(['git', 'rev-list', commitish]).splitlines())
496            else:
497                # Turn single commits or branch or tag names into commit ids.
498                commit_ids += run_command(['git', 'rev-parse', '--revs-only', commitish]).splitlines()
499        return commit_ids
500
501    def commit_message_for_local_commit(self, commit_id):
502        commit_lines = run_command(['git', 'cat-file', 'commit', commit_id]).splitlines()
503
504        # Skip the git headers.
505        first_line_after_headers = 0
506        for line in commit_lines:
507            first_line_after_headers += 1
508            if line == "":
509                break
510        return CommitMessage(commit_lines[first_line_after_headers:])
511
512    def files_changed_summary_for_commit(self, commit_id):
513        return run_command(['git', 'diff-tree', '--shortstat', '--no-commit-id', commit_id])
514