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