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