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         return_code = os.system('curl %s | svn-apply --reviewer "%s"' % (patch['url'], patch['reviewer']))
95         if return_code:
96             raise ScriptError("Patch " + patch['url'] + " failed to download and apply.")
97
98     def run_status_and_extract_filenames(self, status_command, status_regexp):
99         filenames = []
100         for line in self.run_command(status_command).splitlines():
101             match = re.search(status_regexp, line)
102             if not match:
103                 continue
104             # status = match.group('status')
105             filename = match.group('filename')
106             filenames.append(filename)
107         return filenames
108
109     @staticmethod
110     def in_working_directory(path):
111         raise NotImplementedError, "subclasses must implement"
112
113     @staticmethod
114     def find_checkout_root(path):
115         raise NotImplementedError, "subclasses must implement"
116
117     def working_directory_is_clean(self):
118         raise NotImplementedError, "subclasses must implement"
119
120     def clean_working_directory(self):
121         raise NotImplementedError, "subclasses must implement"
122
123     def update_webkit(self):
124         raise NotImplementedError, "subclasses must implement"
125
126     def status_command(self):
127         raise NotImplementedError, "subclasses must implement"
128
129     def changed_files(self):
130         raise NotImplementedError, "subclasses must implement"
131
132     def display_name(self):
133         raise NotImplementedError, "subclasses must implement"
134
135     def create_patch_command(self):
136         raise NotImplementedError, "subclasses must implement"
137
138     def commit_with_message(self, message):
139         raise NotImplementedError, "subclasses must implement"
140     
141     # Subclasses must indicate if they support local commits,
142     # but the SCM baseclass will only call local_commits methods when this is true.
143     def supports_local_commits(self):
144         raise NotImplementedError, "subclasses must implement"
145
146     def discard_local_commits(self):
147         pass
148
149     def local_commits(self):
150         return []
151
152 class SVN(SCM):
153     def __init__(self, cwd, dryrun=False):
154         SCM.__init__(self, cwd, dryrun)
155         self.cached_version = None
156     
157     @staticmethod
158     def in_working_directory(path):
159         return os.path.isdir(os.path.join(path, '.svn'))
160     
161     @staticmethod
162     def find_checkout_root(path):
163         last_path = None
164         while True:
165             if not SVN.in_working_directory(path):
166                 return last_path
167             last_path = path
168             (path, last_component) = os.path.split(path)
169             if last_path == path:
170                 return None
171     
172     def svn_version(self):
173         if not self.cached_version:
174             self.cached_version = self.run_command("svn --version --quiet")
175         
176         return self.cached_version
177
178     def working_directory_is_clean(self):
179         return self.run_command("svn diff") == ""
180
181     def clean_working_directory(self):
182         self.run_command("svn reset -R")
183
184     def update_webkit(self):
185         self.run_command("update-webkit")
186
187     def status_command(self):
188         return 'svn status'
189
190     def changed_files(self):
191         if self.svn_version() > "1.6":
192             status_regexp = "^(?P<status>[ACDMR]).{6} (?P<filename>.+)$"
193         else:
194             status_regexp = "^(?P<status>[ACDMR]).{5} (?P<filename>.+)$"
195         return self.run_status_and_extract_filenames(self.status_command(), status_regexp)
196
197     def supports_local_commits(self):
198         return False
199
200     def display_name(self):
201         return "svn"
202
203     def create_patch_command(self):
204         return "svn-create-patch"
205
206     def commit_with_message(self, message):
207         if self.dryrun:
208             return "Dry run, no remote commit."
209         return self.run_command('svn commit -F -', input=message)
210
211 # All git-specific logic should go here.
212 class Git(SCM):
213     def __init__(self, cwd, dryrun=False):
214         SCM.__init__(self, cwd, dryrun)
215     
216     @classmethod
217     def in_working_directory(cls, path):
218         return cls.run_command("git rev-parse --is-inside-work-tree 2>&1", cwd=path) == "true"
219
220     @classmethod
221     def find_checkout_root(cls, path):
222         # "git rev-parse --show-cdup" would be another way to get to the root
223         (checkout_root, dot_git) = os.path.split(cls.run_command("git rev-parse --git-dir", cwd=path))
224         # If we were using 2.6 # checkout_root = os.path.relpath(checkout_root, path)
225         if not os.path.isabs(checkout_root): # Sometimes git returns relative paths
226             checkout_root = os.path.join(path, checkout_root)
227         return checkout_root
228     
229     def discard_local_commits(self):
230         self.run_command("git reset --hard trunk")
231     
232     def local_commits(self):
233         return self.run_command("git log --pretty=oneline head...trunk").splitlines()
234     
235     def working_directory_is_clean(self):
236         return self.run_command("git diff-index head") == ""
237     
238     def clean_working_directory(self):
239         # Could run git clean here too, but that wouldn't match working_directory_is_clean
240         self.run_command("git reset --hard head")
241     
242     def update_webkit(self):
243         # FIXME: Should probably call update-webkit, no?
244         log("Updating working directory")
245         self.run_command("git svn rebase")
246
247     def status_command(self):
248         return 'git status'
249
250     def changed_files(self):
251         status_command = 'git diff -r --name-status -C -M'
252         status_regexp = '^(?P<status>[ADM])\t(?P<filename>.+)$'
253         return self.run_status_and_extract_filenames(status_command, status_regexp)
254     
255     def supports_local_commits(self):
256         return True
257
258     def display_name(self):
259         return "git"
260
261     def create_patch_command(self):
262         return "git diff head"
263
264     def commit_with_message(self, message):
265         self.commit_locally_with_message(message)
266         return self.push_local_commits_to_server()
267
268     # Git-specific methods:
269     
270     def commit_locally_with_message(self, message):
271         self.run_command('git commit -a -F -', input=message)
272         
273     def push_local_commits_to_server(self):
274         if self.dryrun:
275             return "Dry run, no remote commit."
276         return self.run_command('git svn dcommit')
277
278     def commit_ids_from_range_arguments(self, args, cherry_pick=False):
279         # First get the commit-ids for the passed in revisions.
280         rev_parse_args = ['git', 'rev-parse', '--revs-only'] + args
281         revisions = self.run_command(" ".join(rev_parse_args)).splitlines()
282         
283         if cherry_pick:
284             return revisions
285         
286         # If we're not cherry picking and were only passed one revision, assume "^revision head" aka "revision..head".
287         if len(revisions) < 2:
288             revisions[0] = "^" + revisions[0]
289             revisions.append("head")
290         
291         rev_list_args = ['git', 'rev-list'] + revisions
292         return self.run_command(" ".join(rev_list_args)).splitlines()
293
294     def commit_message_for_commit(self, commit_id):
295         commit_lines = self.run_command("git cat-file commit " + commit_id).splitlines()
296
297         # Skip the git headers.
298         first_line_after_headers = 0
299         for line in commit_lines:
300             first_line_after_headers += 1
301             if line == "":
302                 break
303         return "\n".join(commit_lines[first_line_after_headers:])
304
305     def show_diff_command_for_commit(self, commit_id):
306         return "git diff-tree -p " + commit_id
307
308     def files_changed_summary_for_commit(self, commit_id):
309         return self.run_command("git diff-tree --shortstat --no-commit-id " + commit_id)