2009-06-25 Eric Seidel <eric@webkit.org>
[WebKit-https.git] / WebKitTools / Scripts / modules / scm.py
1 # Copyright (c) 2009, Google Inc. All rights reserved.
2
3 # Redistribution and use in source and binary forms, with or without
4 # modification, are permitted provided that the following conditions are
5 # met:
6
7 #     * Redistributions of source code must retain the above copyright
8 # notice, this list of conditions and the following disclaimer.
9 #     * Redistributions in binary form must reproduce the above
10 # copyright notice, this list of conditions and the following disclaimer
11 # in the documentation and/or other materials provided with the
12 # distribution.
13 #     * Neither the name of Google Inc. nor the names of its
14 # contributors may be used to endorse or promote products derived from
15 # this software without specific prior written permission.
16
17 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
19 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
20 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
21 # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
22 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
23 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
24 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
25 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28 #
29 # Python module for interacting with an SCM system (like SVN or Git)
30
31 import os
32 import re
33 import subprocess
34 import sys
35
36 def log(string):
37     print >> sys.stderr, string
38
39 def error(string):
40     log(string)
41     exit(1)
42
43 def detect_scm_system(path):
44     if SVN.in_working_directory(path):
45         return SVN(cwd=path)
46     
47     if Git.in_working_directory(path):
48         return Git(cwd=path)
49     
50     return None
51
52 class ScriptError(Exception):
53     pass
54
55 class SCM:
56     def __init__(self, cwd, dryrun=False):
57         self.cwd = cwd
58         self.checkout_root = self.find_checkout_root(self.cwd)
59         self.dryrun = dryrun
60
61     @staticmethod
62     def run_command(command, cwd=None, input=None, raise_on_failure=True, return_exit_code=False):
63         stdin = subprocess.PIPE if input else None
64         process = subprocess.Popen(command, stdout=subprocess.PIPE, stdin=stdin, shell=True, cwd=cwd)
65         output = process.communicate(input)[0].rstrip()
66         exit_code = process.wait()
67         if raise_on_failure and exit_code:
68             raise ScriptError("Failed to run " + command)
69         if return_exit_code:
70             return exit_code
71         return output
72
73     def ensure_clean_working_directory(self, force):
74         if not force and not self.working_directory_is_clean():
75             print self.run_command(self.status_command())
76             error("Working directory has modifications, pass --force-clean or --no-clean to continue.")
77         
78         log("Cleaning working directory")
79         self.clean_working_directory()
80     
81     def ensure_no_local_commits(self, force):
82         if not self.supports_local_commits():
83             return
84         commits = self.local_commits()
85         if not len(commits):
86             return
87         if not force:
88             error("Working directory has local commits, pass --force-clean to continue.")
89         self.discard_local_commits()
90
91     def apply_patch(self, patch):
92         # It's possible that the patch was not made from the root directory.
93         # We should detect and handle that case.
94         curl_process = subprocess.Popen(['curl', patch['url']], stdout=subprocess.PIPE)
95         patch_apply_process = subprocess.Popen(['svn-apply', '--reviewer', patch['reviewer']], stdin=curl_process.stdout)
96
97         return_code = patch_apply_process.wait()
98         if return_code:
99             raise ScriptError("Patch " + patch['url'] + " failed to download and apply.")
100
101     def run_status_and_extract_filenames(self, status_command, status_regexp):
102         filenames = []
103         for line in self.run_command(status_command).splitlines():
104             match = re.search(status_regexp, line)
105             if not match:
106                 continue
107             # status = match.group('status')
108             filename = match.group('filename')
109             filenames.append(filename)
110         return filenames
111
112     @staticmethod
113     def in_working_directory(path):
114         raise NotImplementedError, "subclasses must implement"
115
116     @staticmethod
117     def find_checkout_root(path):
118         raise NotImplementedError, "subclasses must implement"
119
120     def working_directory_is_clean(self):
121         raise NotImplementedError, "subclasses must implement"
122
123     def clean_working_directory(self):
124         raise NotImplementedError, "subclasses must implement"
125
126     def update_webkit(self):
127         raise NotImplementedError, "subclasses must implement"
128
129     def status_command(self):
130         raise NotImplementedError, "subclasses must implement"
131
132     def changed_files(self):
133         raise NotImplementedError, "subclasses must implement"
134
135     def display_name(self):
136         raise NotImplementedError, "subclasses must implement"
137
138     def create_patch_command(self):
139         raise NotImplementedError, "subclasses must implement"
140
141     def commit_with_message(self, message):
142         raise NotImplementedError, "subclasses must implement"
143     
144     # Subclasses must indicate if they support local commits,
145     # but the SCM baseclass will only call local_commits methods when this is true.
146     def supports_local_commits(self):
147         raise NotImplementedError, "subclasses must implement"
148
149     def discard_local_commits(self):
150         pass
151
152     def local_commits(self):
153         return []
154
155 class SVN(SCM):
156     def __init__(self, cwd, dryrun=False):
157         SCM.__init__(self, cwd, dryrun)
158         self.cached_version = None
159     
160     @staticmethod
161     def in_working_directory(path):
162         return os.path.isdir(os.path.join(path, '.svn'))
163     
164     @staticmethod
165     def find_checkout_root(path):
166         last_path = None
167         while True:
168             if not SVN.in_working_directory(path):
169                 return last_path
170             last_path = path
171             (path, last_component) = os.path.split(path)
172             if last_path == path:
173                 return None
174     
175     def svn_version(self):
176         if not self.cached_version:
177             self.cached_version = self.run_command("svn --version --quiet")
178         
179         return self.cached_version
180
181     def working_directory_is_clean(self):
182         return self.run_command("svn diff") == ""
183
184     def clean_working_directory(self):
185         self.run_command("svn reset -R")
186
187     def update_webkit(self):
188         self.run_command("update-webkit")
189
190     def status_command(self):
191         return 'svn status'
192
193     def changed_files(self):
194         if self.svn_version() > "1.6":
195             status_regexp = "^(?P<status>[ACDMR]).{6} (?P<filename>.+)$"
196         else:
197             status_regexp = "^(?P<status>[ACDMR]).{5} (?P<filename>.+)$"
198         return self.run_status_and_extract_filenames(self.status_command(), status_regexp)
199
200     def supports_local_commits(self):
201         return False
202
203     def display_name(self):
204         return "svn"
205
206     def create_patch_command(self):
207         return "svn-create-patch"
208
209     def commit_with_message(self, message):
210         if self.dryrun:
211             return "Dry run, no remote commit."
212         return self.run_command('svn commit -F -', input=message)
213
214 # All git-specific logic should go here.
215 class Git(SCM):
216     def __init__(self, cwd, dryrun=False):
217         SCM.__init__(self, cwd, dryrun)
218     
219     @classmethod
220     def in_working_directory(cls, path):
221         return cls.run_command("git rev-parse --is-inside-work-tree 2>&1", cwd=path) == "true"
222
223     @classmethod
224     def find_checkout_root(cls, path):
225         # "git rev-parse --show-cdup" would be another way to get to the root
226         (checkout_root, dot_git) = os.path.split(cls.run_command("git rev-parse --git-dir", cwd=path))
227         # If we were using 2.6 # checkout_root = os.path.relpath(checkout_root, path)
228         if not os.path.isabs(checkout_root): # Sometimes git returns relative paths
229             checkout_root = os.path.join(path, checkout_root)
230         return checkout_root
231     
232     def discard_local_commits(self):
233         self.run_command("git reset --hard trunk")
234     
235     def local_commits(self):
236         return self.run_command("git log --pretty=oneline head...trunk").splitlines()
237     
238     def working_directory_is_clean(self):
239         return self.run_command("git diff-index head") == ""
240     
241     def clean_working_directory(self):
242         # Could run git clean here too, but that wouldn't match working_directory_is_clean
243         self.run_command("git reset --hard head")
244     
245     def update_webkit(self):
246         # FIXME: Should probably call update-webkit, no?
247         log("Updating working directory")
248         self.run_command("git svn rebase")
249
250     def status_command(self):
251         return 'git status'
252
253     def changed_files(self):
254         status_command = 'git diff -r --name-status -C -M'
255         status_regexp = '^(?P<status>[ADM])\t(?P<filename>.+)$'
256         return self.run_status_and_extract_filenames(status_command, status_regexp)
257     
258     def supports_local_commits(self):
259         return True
260
261     def display_name(self):
262         return "git"
263
264     def create_patch_command(self):
265         return "git diff head"
266
267     def commit_with_message(self, message):
268         self.commit_locally_with_message(message)
269         return self.push_local_commits_to_server()
270
271     # Git-specific methods:
272     
273     def commit_locally_with_message(self, message):
274         self.run_command('git commit -a -F -', input=message)
275         
276     def push_local_commits_to_server(self):
277         if self.dryrun:
278             return "Dry run, no remote commit."
279         return self.run_command('git svn dcommit')
280
281     def commit_ids_from_range_arguments(self, args, cherry_pick=False):
282         # First get the commit-ids for the passed in revisions.
283         rev_parse_args = ['git', 'rev-parse', '--revs-only'] + args
284         revisions = self.run_command(" ".join(rev_parse_args)).splitlines()
285         
286         if cherry_pick:
287             return revisions
288         
289         # If we're not cherry picking and were only passed one revision, assume "^revision head" aka "revision..head".
290         if len(revisions) < 2:
291             revisions[0] = "^" + revisions[0]
292             revisions.append("head")
293         
294         rev_list_args = ['git', 'rev-list'] + revisions
295         return self.run_command(" ".join(rev_list_args)).splitlines()
296
297     def commit_message_for_commit(self, commit_id):
298         commit_lines = self.run_command("git cat-file commit " + commit_id).splitlines()
299
300         # Skip the git headers.
301         first_line_after_headers = 0
302         for line in commit_lines:
303             first_line_after_headers += 1
304             if line == "":
305                 break
306         return "\n".join(commit_lines[first_line_after_headers:])
307
308     def show_diff_command_for_commit(self, commit_id):
309         return "git diff-tree -p " + commit_id
310
311     def files_changed_summary_for_commit(self, commit_id):
312         return self.run_command("git diff-tree --shortstat --no-commit-id " + commit_id)