• 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 modules.logging import error, log
38
39def detect_scm_system(path):
40    if SVN.in_working_directory(path):
41        return SVN(cwd=path)
42
43    if Git.in_working_directory(path):
44        return Git(cwd=path)
45
46    return None
47
48def first_non_empty_line_after_index(lines, index=0):
49    first_non_empty_line = index
50    for line in lines[index:]:
51        if re.match("^\s*$", line):
52            first_non_empty_line += 1
53        else:
54            break
55    return first_non_empty_line
56
57
58class CommitMessage:
59    def __init__(self, message):
60        self.message_lines = message[first_non_empty_line_after_index(message, 0):]
61
62    def body(self, lstrip=False):
63        lines = self.message_lines[first_non_empty_line_after_index(self.message_lines, 1):]
64        if lstrip:
65            lines = [line.lstrip() for line in lines]
66        return "\n".join(lines) + "\n"
67
68    def description(self, lstrip=False, strip_url=False):
69        line = self.message_lines[0]
70        if lstrip:
71            line = line.lstrip()
72        if strip_url:
73            line = re.sub("^(\s*)<.+> ", "\1", line)
74        return line
75
76    def message(self):
77        return "\n".join(self.message_lines) + "\n"
78
79
80class ScriptError(Exception):
81    pass
82
83
84class SCM:
85    def __init__(self, cwd, dryrun=False):
86        self.cwd = cwd
87        self.checkout_root = self.find_checkout_root(self.cwd)
88        self.dryrun = dryrun
89
90    @staticmethod
91    def run_command(args, cwd=None, input=None, raise_on_failure=True, return_exit_code=False):
92        stdin = subprocess.PIPE if input else None
93        process = subprocess.Popen(args, stdout=subprocess.PIPE, stdin=stdin, cwd=cwd)
94        output = process.communicate(input)[0].rstrip()
95        exit_code = process.wait()
96        if raise_on_failure and exit_code:
97            raise ScriptError('Failed to run "%s"  exit_code: %d  cwd: %s' % (args, exit_code, cwd))
98        if return_exit_code:
99            return exit_code
100        return output
101
102    def script_path(self, script_name):
103        return os.path.join(self.checkout_root, "WebKitTools", "Scripts", script_name)
104
105    def ensure_clean_working_directory(self, force):
106        if not force and not self.working_directory_is_clean():
107            print self.run_command(self.status_command(), raise_on_failure=False)
108            raise ScriptError("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        curl_process = subprocess.Popen(['curl', patch['url']], stdout=subprocess.PIPE)
127        args = [self.script_path('svn-apply'), '--reviewer', patch['reviewer']]
128        if force:
129            args.append('--force')
130        patch_apply_process = subprocess.Popen(args, stdin=curl_process.stdout)
131
132        return_code = patch_apply_process.wait()
133        if return_code:
134            raise ScriptError("Patch %s from bug %s failed to download and apply." % (patch['url'], patch['bug_id']))
135
136    def run_status_and_extract_filenames(self, status_command, status_regexp):
137        filenames = []
138        for line in self.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    @staticmethod
148    def in_working_directory(path):
149        raise NotImplementedError, "subclasses must implement"
150
151    @staticmethod
152    def find_checkout_root(path):
153        raise NotImplementedError, "subclasses must implement"
154
155    @staticmethod
156    def commit_success_regexp():
157        raise NotImplementedError, "subclasses must implement"
158
159    def working_directory_is_clean(self):
160        raise NotImplementedError, "subclasses must implement"
161
162    def clean_working_directory(self):
163        raise NotImplementedError, "subclasses must implement"
164
165    def update_webkit(self):
166        raise NotImplementedError, "subclasses must implement"
167
168    def status_command(self):
169        raise NotImplementedError, "subclasses must implement"
170
171    def changed_files(self):
172        raise NotImplementedError, "subclasses must implement"
173
174    def display_name(self):
175        raise NotImplementedError, "subclasses must implement"
176
177    def create_patch(self):
178        raise NotImplementedError, "subclasses must implement"
179
180    def commit_with_message(self, message):
181        raise NotImplementedError, "subclasses must implement"
182
183    # Subclasses must indicate if they support local commits,
184    # but the SCM baseclass will only call local_commits methods when this is true.
185    @staticmethod
186    def supports_local_commits():
187        raise NotImplementedError, "subclasses must implement"
188
189    def create_patch_from_local_commit(self, commit_id):
190        error("Your source control manager does not support creating a patch from a local commit.")
191
192    def create_patch_since_local_commit(self, commit_id):
193        error("Your source control manager does not support creating a patch from a local commit.")
194
195    def commit_locally_with_message(self, message):
196        error("Your source control manager does not support local commits.")
197
198    def discard_local_commits(self):
199        pass
200
201    def local_commits(self):
202        return []
203
204
205class SVN(SCM):
206    def __init__(self, cwd, dryrun=False):
207        SCM.__init__(self, cwd, dryrun)
208        self.cached_version = None
209
210    @staticmethod
211    def in_working_directory(path):
212        return os.path.isdir(os.path.join(path, '.svn'))
213
214    @staticmethod
215    def find_uuid(path):
216        if not SVN.in_working_directory(path):
217            return None
218        info = SVN.run_command(['svn', 'info', path])
219        match = re.search("^Repository UUID: (?P<uuid>.+)$", info, re.MULTILINE)
220        if not match:
221            raise ScriptError('svn info did not contain a UUID.')
222        return match.group('uuid')
223
224    @staticmethod
225    def find_checkout_root(path):
226        uuid = SVN.find_uuid(path)
227        # If |path| is not in a working directory, we're supposed to return |path|.
228        if not uuid:
229            return path
230        # Search up the directory hierarchy until we find a different UUID.
231        last_path = None
232        while True:
233            if uuid != SVN.find_uuid(path):
234                return last_path
235            last_path = path
236            (path, last_component) = os.path.split(path)
237            if last_path == path:
238                return None
239
240    @staticmethod
241    def commit_success_regexp():
242        return "^Committed revision (?P<svn_revision>\d+)\.$"
243
244    def svn_version(self):
245        if not self.cached_version:
246            self.cached_version = self.run_command(['svn', '--version', '--quiet'])
247
248        return self.cached_version
249
250    def working_directory_is_clean(self):
251        return self.run_command(['svn', 'diff']) == ""
252
253    def clean_working_directory(self):
254        self.run_command(['svn', 'revert', '-R', '.'])
255
256    def update_webkit(self):
257        self.run_command(self.script_path("update-webkit"))
258
259    def status_command(self):
260        return ['svn', 'status']
261
262    def changed_files(self):
263        if self.svn_version() > "1.6":
264            status_regexp = "^(?P<status>[ACDMR]).{6} (?P<filename>.+)$"
265        else:
266            status_regexp = "^(?P<status>[ACDMR]).{5} (?P<filename>.+)$"
267        return self.run_status_and_extract_filenames(self.status_command(), status_regexp)
268
269    @staticmethod
270    def supports_local_commits():
271        return False
272
273    def display_name(self):
274        return "svn"
275
276    def create_patch(self):
277        return self.run_command(self.script_path("svn-create-patch"))
278
279    def commit_with_message(self, message):
280        if self.dryrun:
281            return "Dry run, no remote commit."
282        return self.run_command(['svn', 'commit', '-m', message])
283
284
285# All git-specific logic should go here.
286class Git(SCM):
287    def __init__(self, cwd, dryrun=False):
288        SCM.__init__(self, cwd, dryrun)
289
290    @classmethod
291    def in_working_directory(cls, path):
292        return cls.run_command(['git', 'rev-parse', '--is-inside-work-tree'], cwd=path) == "true"
293
294    @classmethod
295    def find_checkout_root(cls, path):
296        # "git rev-parse --show-cdup" would be another way to get to the root
297        (checkout_root, dot_git) = os.path.split(cls.run_command(['git', 'rev-parse', '--git-dir'], cwd=path))
298        # If we were using 2.6 # checkout_root = os.path.relpath(checkout_root, path)
299        if not os.path.isabs(checkout_root): # Sometimes git returns relative paths
300            checkout_root = os.path.join(path, checkout_root)
301        return checkout_root
302
303    @staticmethod
304    def commit_success_regexp():
305        return "^Committed r(?P<svn_revision>\d+)$"
306
307    def discard_local_commits(self):
308        self.run_command(['git', 'reset', '--hard', 'trunk'])
309
310    def local_commits(self):
311        return self.run_command(['git', 'log', '--pretty=oneline', 'HEAD...trunk']).splitlines()
312
313    def working_directory_is_clean(self):
314        return self.run_command(['git', 'diff-index', 'HEAD']) == ""
315
316    def clean_working_directory(self):
317        # Could run git clean here too, but that wouldn't match working_directory_is_clean
318        self.run_command(['git', 'reset', '--hard', 'HEAD'])
319
320    def update_webkit(self):
321        # FIXME: Should probably call update-webkit, no?
322        log("Updating working directory")
323        self.run_command(['git', 'svn', 'rebase'])
324
325    def status_command(self):
326        return ['git', 'status']
327
328    def changed_files(self):
329        status_command = ['git', 'diff', '-r', '--name-status', '-C', '-M', 'HEAD']
330        status_regexp = '^(?P<status>[ADM])\t(?P<filename>.+)$'
331        return self.run_status_and_extract_filenames(status_command, status_regexp)
332
333    @staticmethod
334    def supports_local_commits():
335        return True
336
337    def display_name(self):
338        return "git"
339
340    def create_patch(self):
341        return self.run_command(['git', 'diff', 'HEAD'])
342
343    def commit_with_message(self, message):
344        self.commit_locally_with_message(message)
345        return self.push_local_commits_to_server()
346
347    # Git-specific methods:
348
349    def create_patch_from_local_commit(self, commit_id):
350        return self.run_command(['git', 'diff', commit_id + "^.." + commit_id])
351
352    def create_patch_since_local_commit(self, commit_id):
353        return self.run_command(['git', 'diff', commit_id])
354
355    def commit_locally_with_message(self, message):
356        self.run_command(['git', 'commit', '--all', '-F', '-'], input=message)
357
358    def push_local_commits_to_server(self):
359        if self.dryrun:
360            return "Dry run, no remote commit."
361        return self.run_command(['git', 'svn', 'dcommit'])
362
363    # This function supports the following argument formats:
364    # no args : rev-list trunk..HEAD
365    # A..B    : rev-list A..B
366    # A...B   : error!
367    # A B     : [A, B]  (different from git diff, which would use "rev-list A..B")
368    def commit_ids_from_commitish_arguments(self, args):
369        if not len(args):
370            # FIXME: trunk is not always the remote branch name, need a way to detect the name.
371            args.append('trunk..HEAD')
372
373        commit_ids = []
374        for commitish in args:
375            if '...' in commitish:
376                raise ScriptError("'...' is not supported (found in '%s'). Did you mean '..'?" % commitish)
377            elif '..' in commitish:
378                commit_ids += self.run_command(['git', 'rev-list', commitish]).splitlines()
379            else:
380                # Turn single commits or branch or tag names into commit ids.
381                commit_ids += self.run_command(['git', 'rev-parse', '--revs-only', commitish]).splitlines()
382        return commit_ids
383
384    def commit_message_for_local_commit(self, commit_id):
385        commit_lines = self.run_command(['git', 'cat-file', 'commit', commit_id]).splitlines()
386
387        # Skip the git headers.
388        first_line_after_headers = 0
389        for line in commit_lines:
390            first_line_after_headers += 1
391            if line == "":
392                break
393        return CommitMessage(commit_lines[first_line_after_headers:])
394
395    def files_changed_summary_for_commit(self, commit_id):
396        return self.run_command(['git', 'diff-tree', '--shortstat', '--no-commit-id', commit_id])
397