diff options
| author | Simon Hausmann <simon.hausmann@nokia.com> | 2012-01-06 14:44:00 +0100 |
|---|---|---|
| committer | Simon Hausmann <simon.hausmann@nokia.com> | 2012-01-06 14:44:00 +0100 |
| commit | 40736c5763bf61337c8c14e16d8587db021a87d4 (patch) | |
| tree | b17a9c00042ad89cb1308e2484491799aa14e9f8 /Tools/Scripts/webkitpy/common/checkout | |
| download | qtwebkit-40736c5763bf61337c8c14e16d8587db021a87d4.tar.gz | |
Imported WebKit commit 2ea9d364d0f6efa8fa64acf19f451504c59be0e4 (http://svn.webkit.org/repository/webkit/trunk@104285)
Diffstat (limited to 'Tools/Scripts/webkitpy/common/checkout')
23 files changed, 5319 insertions, 0 deletions
diff --git a/Tools/Scripts/webkitpy/common/checkout/__init__.py b/Tools/Scripts/webkitpy/common/checkout/__init__.py new file mode 100644 index 000000000..f385ae4f1 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/__init__.py @@ -0,0 +1,3 @@ +# Required for Python to search this directory for module files + +from .checkout import Checkout diff --git a/Tools/Scripts/webkitpy/common/checkout/baselineoptimizer.py b/Tools/Scripts/webkitpy/common/checkout/baselineoptimizer.py new file mode 100644 index 000000000..4e4dc6c49 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/baselineoptimizer.py @@ -0,0 +1,163 @@ +# Copyright (C) 2011, Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +# Yes, it's a hypergraph. +# FIXME: Should this function live with the ports somewhere? +# Perhaps this should move onto PortFactory? +def _baseline_search_hypergraph(host): + hypergraph = {} + + # These edges in the hypergraph aren't visible on build.webkit.org, + # but they impose constraints on how we optimize baselines. + hypergraph['mac-future'] = ['LayoutTests/platform/mac-future', 'LayoutTests/platform/mac', 'LayoutTests'] + hypergraph['qt-unknown'] = ['LayoutTests/platform/qt-unknown', 'LayoutTests/platform/qt', 'LayoutTests'] + + # FIXME: Should we get this constant from somewhere? + fallback_path = ['LayoutTests'] + + port_factory = host.port_factory + for port_name in port_factory.all_port_names(): + port = port_factory.get(port_name) + webkit_base = port.webkit_base() + search_path = port.baseline_search_path() + if search_path: + hypergraph[port_name] = [host.filesystem.relpath(path, webkit_base) for path in search_path] + fallback_path + return hypergraph + + +# FIXME: Should this function be somewhere more general? +def _invert_dictionary(dictionary): + inverted_dictionary = {} + for key, value in dictionary.items(): + if inverted_dictionary.get(value): + inverted_dictionary[value].append(key) + else: + inverted_dictionary[value] = [key] + return inverted_dictionary + + +class BaselineOptimizer(object): + def __init__(self, host): + self._host = host + self._filesystem = self._host.filesystem + self._scm = self._host.scm() + self._hypergraph = _baseline_search_hypergraph(host) + self._directories = reduce(set.union, map(set, self._hypergraph.values())) + + def _read_results_by_directory(self, baseline_name): + results_by_directory = {} + for directory in self._directories: + path = self._filesystem.join(self._scm.checkout_root, directory, baseline_name) + if self._filesystem.exists(path): + results_by_directory[directory] = self._filesystem.sha1(path) + return results_by_directory + + def _results_by_port_name(self, results_by_directory): + results_by_port_name = {} + for port_name, search_path in self._hypergraph.items(): + for directory in search_path: + if directory in results_by_directory: + results_by_port_name[port_name] = results_by_directory[directory] + break + return results_by_port_name + + def _most_specific_common_directory(self, port_names): + paths = [self._hypergraph[port_name] for port_name in port_names] + common_directories = reduce(set.intersection, map(set, paths)) + + def score(directory): + return sum([path.index(directory) for path in paths]) + + _, directory = sorted([(score(directory), directory) for directory in common_directories])[0] + return directory + + def _filter_port_names_by_result(self, predicate, port_names_by_result): + filtered_port_names_by_result = {} + for result, port_names in port_names_by_result.items(): + filtered_port_names = filter(predicate, port_names) + if filtered_port_names: + filtered_port_names_by_result[result] = filtered_port_names + return filtered_port_names_by_result + + def _place_results_in_most_specific_common_directory(self, port_names_by_result, results_by_directory): + for result, port_names in port_names_by_result.items(): + directory = self._most_specific_common_directory(port_names) + results_by_directory[directory] = result + + def _find_optimal_result_placement(self, baseline_name): + results_by_directory = self._read_results_by_directory(baseline_name) + results_by_port_name = self._results_by_port_name(results_by_directory) + port_names_by_result = _invert_dictionary(results_by_port_name) + + new_results_by_directory = {} + unsatisfied_port_names_by_result = port_names_by_result + while unsatisfied_port_names_by_result: + self._place_results_in_most_specific_common_directory(unsatisfied_port_names_by_result, new_results_by_directory) + new_results_by_port_name = self._results_by_port_name(new_results_by_directory) + + def is_unsatisfied(port_name): + return results_by_port_name[port_name] != new_results_by_port_name[port_name] + + new_unsatisfied_port_names_by_result = self._filter_port_names_by_result(is_unsatisfied, port_names_by_result) + + if len(new_unsatisfied_port_names_by_result.values()) >= len(unsatisfied_port_names_by_result.values()): + break # Frowns. We do not appear to be converging. + unsatisfied_port_names_by_result = new_unsatisfied_port_names_by_result + + return results_by_directory, new_results_by_directory + + def _move_baselines(self, baseline_name, results_by_directory, new_results_by_directory): + data_for_result = {} + for directory, result in results_by_directory.items(): + if not result in data_for_result: + source = self._filesystem.join(self._scm.checkout_root, directory, baseline_name) + data_for_result[result] = self._filesystem.read_binary_file(source) + + for directory, result in results_by_directory.items(): + if new_results_by_directory.get(directory) != result: + file_name = self._filesystem.join(self._scm.checkout_root, directory, baseline_name) + self._scm.delete(file_name) + + for directory, result in new_results_by_directory.items(): + if results_by_directory.get(directory) != result: + destination = self._filesystem.join(self._scm.checkout_root, directory, baseline_name) + self._filesystem.maybe_make_directory(self._filesystem.split(destination)[0]) + self._filesystem.write_binary_file(destination, data_for_result[result]) + self._scm.add(destination) + + def directories_by_result(self, baseline_name): + results_by_directory = self._read_results_by_directory(baseline_name) + return _invert_dictionary(results_by_directory) + + def optimize(self, baseline_name): + results_by_directory, new_results_by_directory = self._find_optimal_result_placement(baseline_name) + if self._results_by_port_name(results_by_directory) != self._results_by_port_name(new_results_by_directory): + return False + self._move_baselines(baseline_name, results_by_directory, new_results_by_directory) + return True diff --git a/Tools/Scripts/webkitpy/common/checkout/baselineoptimizer_unittest.py b/Tools/Scripts/webkitpy/common/checkout/baselineoptimizer_unittest.py new file mode 100644 index 000000000..72d7a7ebf --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/baselineoptimizer_unittest.py @@ -0,0 +1,157 @@ +# Copyright (C) 2011 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import sys +import unittest + +from webkitpy.common.checkout.baselineoptimizer import BaselineOptimizer +from webkitpy.common.system.filesystem_mock import MockFileSystem +from webkitpy.common.host_mock import MockHost + + +class TestBaselineOptimizer(BaselineOptimizer): + def __init__(self, mock_results_by_directory): + host = MockHost() + BaselineOptimizer.__init__(self, host) + self._mock_results_by_directory = mock_results_by_directory + + # We override this method for testing so we don't have to construct an + # elaborate mock file system. + def _read_results_by_directory(self, baseline_name): + return self._mock_results_by_directory + + +class BaselineOptimizerTest(unittest.TestCase): + def _assertOptimization(self, results_by_directory, expected_new_results_by_directory): + baseline_optimizer = TestBaselineOptimizer(results_by_directory) + _, new_results_by_directory = baseline_optimizer._find_optimal_result_placement('mock-baseline.png') + self.assertEqual(new_results_by_directory, expected_new_results_by_directory) + + def test_move_baselines(self): + host = MockHost() + host.filesystem.write_binary_file('/mock-checkout/LayoutTests/platform/chromium-win/another/test-expected.txt', 'result A') + host.filesystem.write_binary_file('/mock-checkout/LayoutTests/platform/chromium-cg-mac/another/test-expected.txt', 'result A') + host.filesystem.write_binary_file('/mock-checkout/LayoutTests/platform/chromium/another/test-expected.txt', 'result B') + baseline_optimizer = BaselineOptimizer(host) + baseline_optimizer._move_baselines('another/test-expected.txt', { + 'LayoutTests/platform/chromium-win': 'aaa', + 'LayoutTests/platform/chromium-cg-mac': 'aaa', + 'LayoutTests/platform/chromium': 'bbb', + }, { + 'LayoutTests/platform/chromium': 'aaa', + }) + self.assertEqual(host.filesystem.read_binary_file('/mock-checkout/LayoutTests/platform/chromium/another/test-expected.txt'), 'result A') + + def test_chromium_linux_redundant_with_win(self): + self._assertOptimization({ + 'LayoutTests/platform/chromium-win': '462d03b9c025db1b0392d7453310dbee5f9a9e74', + 'LayoutTests/platform/chromium-linux': '462d03b9c025db1b0392d7453310dbee5f9a9e74', + }, { + 'LayoutTests/platform/chromium-win': '462d03b9c025db1b0392d7453310dbee5f9a9e74', + }) + + def test_chromium_covers_mac_win_linux(self): + self._assertOptimization({ + 'LayoutTests/platform/chromium-cg-mac': '462d03b9c025db1b0392d7453310dbee5f9a9e74', + 'LayoutTests/platform/chromium-win': '462d03b9c025db1b0392d7453310dbee5f9a9e74', + 'LayoutTests/platform/chromium-linux': '462d03b9c025db1b0392d7453310dbee5f9a9e74', + }, { + 'LayoutTests/platform/chromium': '462d03b9c025db1b0392d7453310dbee5f9a9e74', + }) + + def test_chromium_mac_redundant_with_apple_mac(self): + self._assertOptimization({ + 'LayoutTests/platform/chromium-cg-mac-snowleopard': '462d03b9c025db1b0392d7453310dbee5f9a9e74', + 'LayoutTests/platform/mac-snowleopard': '462d03b9c025db1b0392d7453310dbee5f9a9e74', + }, { + 'LayoutTests/platform/mac-snowleopard': '462d03b9c025db1b0392d7453310dbee5f9a9e74', + }) + + def test_mac_future(self): + self._assertOptimization({ + 'LayoutTests/platform/mac-snowleopard': '462d03b9c025db1b0392d7453310dbee5f9a9e74', + }, { + 'LayoutTests/platform/mac-snowleopard': '462d03b9c025db1b0392d7453310dbee5f9a9e74', + }) + + def test_qt_unknown(self): + self._assertOptimization({ + 'LayoutTests/platform/qt': '462d03b9c025db1b0392d7453310dbee5f9a9e74', + }, { + 'LayoutTests/platform/qt': '462d03b9c025db1b0392d7453310dbee5f9a9e74', + }) + + def test_common_directory_includes_root(self): + # Note: The resulting directories are "wrong" in the sense that + # enacting this plan would change semantics. However, this test case + # demonstrates that we don't throw an exception in this case. :) + self._assertOptimization({ + 'LayoutTests/platform/gtk': 'e8608763f6241ddacdd5c1ef1973ba27177d0846', + 'LayoutTests/platform/qt': 'bcbd457d545986b7abf1221655d722363079ac87', + 'LayoutTests/platform/chromium-win': '3764ac11e1f9fbadd87a90a2e40278319190a0d3', + 'LayoutTests/platform/mac': 'e8608763f6241ddacdd5c1ef1973ba27177d0846', + }, { + 'LayoutTests/platform/qt': 'bcbd457d545986b7abf1221655d722363079ac87', + 'LayoutTests/platform/chromium-win': '3764ac11e1f9fbadd87a90a2e40278319190a0d3', + 'LayoutTests': 'e8608763f6241ddacdd5c1ef1973ba27177d0846', + }) + + self._assertOptimization({ + 'LayoutTests/platform/chromium-win': '23a30302a6910f8a48b1007fa36f3e3158341834', + 'LayoutTests': '9c876f8c3e4cc2aef9519a6c1174eb3432591127', + 'LayoutTests/platform/chromium-cg-mac': '23a30302a6910f8a48b1007fa36f3e3158341834', + 'LayoutTests/platform/chromium-mac': '23a30302a6910f8a48b1007fa36f3e3158341834', + }, { + 'LayoutTests/platform/chromium': '23a30302a6910f8a48b1007fa36f3e3158341834', + 'LayoutTests': '9c876f8c3e4cc2aef9519a6c1174eb3432591127', + }) + + def test_complex_shadowing(self): + # This test relies on OS specific functionality, so it doesn't work on Windows. + # FIXME: What functionality does this rely on? When can we remove this if? + if sys.platform == 'win32': + return + self._assertOptimization({ + 'LayoutTests/platform/chromium-win': '462d03b9c025db1b0392d7453310dbee5f9a9e74', + 'LayoutTests/platform/mac': '5daa78e55f05d9f0d1bb1f32b0cd1bc3a01e9364', + 'LayoutTests/platform/chromium-win-xp': '462d03b9c025db1b0392d7453310dbee5f9a9e74', + 'LayoutTests/platform/chromium-cg-mac-leopard': '65e7d42f8b4882b29d46dc77bb879dd41bc074dc', + 'LayoutTests/platform/mac-leopard': '7ad045ece7c030e2283c5d21d9587be22bcba56e', + 'LayoutTests/platform/chromium-win-vista': 'f83af9732ce74f702b8c9c4a3d9a4c6636b8d3bd', + 'LayoutTests/platform/win': '5b1253ef4d5094530d5f1bc6cdb95c90b446bec7', + 'LayoutTests/platform/chromium-linux': 'f52fcdde9e4be8bd5142171cd859230bd4471036', + }, { + 'LayoutTests/platform/chromium-win': '462d03b9c025db1b0392d7453310dbee5f9a9e74', + 'LayoutTests/platform/mac': '5daa78e55f05d9f0d1bb1f32b0cd1bc3a01e9364', + 'LayoutTests/platform/chromium-win-xp': '462d03b9c025db1b0392d7453310dbee5f9a9e74', + 'LayoutTests/platform/chromium-cg-mac-leopard': '65e7d42f8b4882b29d46dc77bb879dd41bc074dc', + 'LayoutTests/platform/mac-leopard': '7ad045ece7c030e2283c5d21d9587be22bcba56e', + 'LayoutTests/platform/chromium-win-vista': 'f83af9732ce74f702b8c9c4a3d9a4c6636b8d3bd', + 'LayoutTests/platform/win': '5b1253ef4d5094530d5f1bc6cdb95c90b446bec7', + 'LayoutTests/platform/chromium-linux': 'f52fcdde9e4be8bd5142171cd859230bd4471036' + }) diff --git a/Tools/Scripts/webkitpy/common/checkout/changelog.py b/Tools/Scripts/webkitpy/common/checkout/changelog.py new file mode 100644 index 000000000..fd41871a0 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/changelog.py @@ -0,0 +1,366 @@ +# Copyright (C) 2009, Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# WebKit's Python module for parsing and modifying ChangeLog files + +import codecs +import fileinput # inplace file editing for set_reviewer_in_changelog +import re +import textwrap + +from webkitpy.common.config.committers import CommitterList +from webkitpy.common.config.committers import Account +import webkitpy.common.config.urls as config_urls +from webkitpy.common.system.deprecated_logging import log + + +# FIXME: parse_bug_id should not be a free function. +# FIXME: Where should this function live in the dependency graph? +def parse_bug_id(message): + if not message: + return None + match = re.search(config_urls.bug_url_short, message) + if match: + return int(match.group('bug_id')) + match = re.search(config_urls.bug_url_long, message) + if match: + return int(match.group('bug_id')) + return None + + +# FIXME: parse_bug_id_from_changelog should not be a free function. +# Parse the bug ID out of a Changelog message based on the format that is +# used by prepare-ChangeLog +def parse_bug_id_from_changelog(message): + if not message: + return None + match = re.search("^\s*" + config_urls.bug_url_short + "$", message, re.MULTILINE) + if match: + return int(match.group('bug_id')) + match = re.search("^\s*" + config_urls.bug_url_long + "$", message, re.MULTILINE) + if match: + return int(match.group('bug_id')) + # We weren't able to find a bug URL in the format used by prepare-ChangeLog. Fall back to the + # first bug URL found anywhere in the message. + return parse_bug_id(message) + + +class ChangeLogEntry(object): + # e.g. 2009-06-03 Eric Seidel <eric@webkit.org> + date_line_regexp = r'^(?P<date>\d{4}-\d{2}-\d{2})\s+(?P<authors>(?P<name>[^<]+?)\s+<(?P<email>[^<>]+)>.*?)$' + + # e.g. * Source/WebCore/page/EventHandler.cpp: Implement FooBarQuux. + touched_files_regexp = r'^\s*\*\s*(?P<file>[A-Za-z0-9_\-\./\\]+)\s*\:' + + # e.g. Reviewed by Darin Adler. + # (Discard everything after the first period to match more invalid lines.) + reviewed_by_regexp = r'^\s*((\w+\s+)+and\s+)?(Review|Rubber(\s*|-)stamp)(s|ed)?\s+([a-z]+\s+)*?by\s+(?P<reviewer>.*?)[\.,]?\s*$' + + reviewed_byless_regexp = r'^\s*((Review|Rubber(\s*|-)stamp)(s|ed)?|RS)(\s+|\s*=\s*)(?P<reviewer>([A-Z]\w+\s*)+)[\.,]?\s*$' + + reviewer_name_noise_regexp = re.compile(r""" + (\s+((tweaked\s+)?and\s+)?(landed|committed|okayed)\s+by.+) # "landed by", "commented by", etc... + |(^(Reviewed\s+)?by\s+) # extra "Reviewed by" or "by" + |\.(?:(\s.+|$)) # text after the first period followed by a space + |([(<]\s*[\w_\-\.]+@[\w_\-\.]+[>)]) # email addresses + |([(<](https?://?bugs.)webkit.org[^>)]+[>)]) # bug url + |("[^"]+") # wresler names like 'Sean/Shawn/Shaun' in 'Geoffrey "Sean/Shawn/Shaun" Garen' + |('[^']+') # wresler names like "The Belly" in "Sam 'The Belly' Weinig" + |((Mr|Ms|Dr|Mrs|Prof)\.(\s+|$)) + """, re.IGNORECASE | re.VERBOSE) + + reviewer_name_casesensitive_noise_regexp = re.compile(r""" + ((\s+|^)(and\s+)?([a-z-]+\s+){5,}by\s+) # e.g. "and given a good once-over by" + |(\(\s*(?!(and|[A-Z])).+\)) # any parenthesis that doesn't start with "and" or a capital letter + |(with(\s+[a-z-]+)+) # phrases with "with no hesitation" in "Sam Weinig with no hesitation" + """, re.VERBOSE) + + nobody_regexp = re.compile(r"""(\s+|^)nobody( + ((,|\s+-)?\s+(\w+\s+)+fix.*) # e.g. nobody, build fix... + |(\s*\([^)]+\).*) # NOBODY (..)... + |$)""", re.IGNORECASE | re.VERBOSE) + + # e.g. == Rolled over to ChangeLog-2011-02-16 == + rolled_over_regexp = r'^== Rolled over to ChangeLog-\d{4}-\d{2}-\d{2} ==$' + + # e.g. git-svn-id: http://svn.webkit.org/repository/webkit/trunk@96161 268f45cc-cd09-0410-ab3c-d52691b4dbfc + svn_id_regexp = r'git-svn-id: http://svn.webkit.org/repository/webkit/trunk@(?P<svnid>\d+) ' + + def __init__(self, contents, committer_list=CommitterList(), revision=None): + self._contents = contents + self._committer_list = committer_list + self._revision = revision + self._parse_entry() + + @staticmethod + def _parse_reviewer_text(text): + match = re.search(ChangeLogEntry.reviewed_by_regexp, text, re.MULTILINE | re.IGNORECASE) + if not match: + # There are cases where people omit "by". We match it only if reviewer part looked nice + # in order to avoid matching random lines that start with Reviewed + match = re.search(ChangeLogEntry.reviewed_byless_regexp, text, re.MULTILINE | re.IGNORECASE) + if not match: + return None, None + + reviewer_text = match.group("reviewer") + + reviewer_text = ChangeLogEntry.nobody_regexp.sub('', reviewer_text) + reviewer_text = ChangeLogEntry.reviewer_name_noise_regexp.sub('', reviewer_text) + reviewer_text = ChangeLogEntry.reviewer_name_casesensitive_noise_regexp.sub('', reviewer_text) + reviewer_text = reviewer_text.replace('(', '').replace(')', '') + reviewer_text = re.sub(r'\s\s+|[,.]\s*$', ' ', reviewer_text).strip() + if not len(reviewer_text): + return None, None + + reviewer_list = ChangeLogEntry._split_contributor_names(reviewer_text) + + # Get rid of "reviewers" like "even though this is just a..." in "Reviewed by Sam Weinig, even though this is just a..." + # and "who wrote the original code" in "Noam Rosenthal, who wrote the original code" + reviewer_list = [reviewer for reviewer in reviewer_list if not re.match('^who\s|^([a-z]+(\s+|\.|$)){6,}$', reviewer)] + + return reviewer_text, reviewer_list + + @staticmethod + def _split_contributor_names(text): + return re.split(r'\s*(?:,(?:\s+and\s+|&)?|(?:^|\s+)and\s+|[/+&])\s*', text) + + def _fuzz_match_reviewers(self, reviewers_text_list): + if not reviewers_text_list: + return [] + list_of_reviewers = [self._committer_list.contributors_by_fuzzy_match(reviewer)[0] for reviewer in reviewers_text_list] + # Flatten lists and get rid of any reviewers with more than one candidate. + return [reviewers[0] for reviewers in list_of_reviewers if len(reviewers) == 1] + + @staticmethod + def _parse_author_name_and_email(author_name_and_email): + match = re.match(r'(?P<name>.+?)\s+<(?P<email>[^>]+)>', author_name_and_email) + return {'name': match.group("name"), 'email': match.group("email")} + + @staticmethod + def _parse_author_text(text): + if not text: + return [] + authors = ChangeLogEntry._split_contributor_names(text) + assert(authors and len(authors) >= 1) + return [ChangeLogEntry._parse_author_name_and_email(author) for author in authors] + + def _parse_entry(self): + match = re.match(self.date_line_regexp, self._contents, re.MULTILINE) + if not match: + log("WARNING: Creating invalid ChangeLogEntry:\n%s" % self._contents) + + # FIXME: group("name") does not seem to be Unicode? Probably due to self._contents not being unicode. + self._author_text = match.group("authors") if match else None + self._authors = ChangeLogEntry._parse_author_text(self._author_text) + + self._reviewer_text, self._reviewers_text_list = ChangeLogEntry._parse_reviewer_text(self._contents) + self._reviewers = self._fuzz_match_reviewers(self._reviewers_text_list) + self._author = self._committer_list.contributor_by_email(self.author_email()) or self._committer_list.contributor_by_name(self.author_name()) + + self._touched_files = re.findall(self.touched_files_regexp, self._contents, re.MULTILINE) + + def author_text(self): + return self._author_text + + def revision(self): + return self._revision + + def author_name(self): + return self._authors[0]['name'] + + def author_email(self): + return self._authors[0]['email'] + + def author(self): + return self._author # Might be None + + def authors(self): + return self._authors + + # FIXME: Eventually we would like to map reviwer names to reviewer objects. + # See https://bugs.webkit.org/show_bug.cgi?id=26533 + def reviewer_text(self): + return self._reviewer_text + + # Might be None, might also not be a Reviewer! + def reviewer(self): + return self._reviewers[0] if len(self._reviewers) > 0 else None + + def reviewers(self): + return self._reviewers + + def has_valid_reviewer(self): + if self._reviewers_text_list: + for reviewer in self._reviewers_text_list: + reviewer = self._committer_list.committer_by_name(reviewer) + if reviewer: + return True + return bool(re.search("unreviewed", self._contents, re.IGNORECASE)) + + def contents(self): + return self._contents + + def bug_id(self): + return parse_bug_id_from_changelog(self._contents) + + def touched_files(self): + return self._touched_files + + +# FIXME: Various methods on ChangeLog should move into ChangeLogEntry instead. +class ChangeLog(object): + + def __init__(self, path): + self.path = path + + _changelog_indent = " " * 8 + + @staticmethod + def parse_latest_entry_from_file(changelog_file): + """changelog_file must be a file-like object which returns + unicode strings. Use codecs.open or StringIO(unicode()) + to pass file objects to this class.""" + date_line_regexp = re.compile(ChangeLogEntry.date_line_regexp) + rolled_over_regexp = re.compile(ChangeLogEntry.rolled_over_regexp) + entry_lines = [] + # The first line should be a date line. + first_line = changelog_file.readline() + assert(isinstance(first_line, unicode)) + if not date_line_regexp.match(first_line): + return None + entry_lines.append(first_line) + + for line in changelog_file: + # If we've hit the next entry, return. + if date_line_regexp.match(line) or rolled_over_regexp.match(line): + # Remove the extra newline at the end + return ChangeLogEntry(''.join(entry_lines[:-1])) + entry_lines.append(line) + return None # We never found a date line! + + svn_blame_regexp = re.compile(r'^(\s*(?P<revision>\d+) [^ ]+)\s(?P<line>.*?\n)') + + @staticmethod + def _separate_revision_and_line(line): + match = ChangeLog.svn_blame_regexp.match(line) + if not match: + return None, line + return int(match.group('revision')), match.group('line') + + @staticmethod + def parse_entries_from_file(changelog_file): + """changelog_file must be a file-like object which returns + unicode strings. Use codecs.open or StringIO(unicode()) + to pass file objects to this class.""" + date_line_regexp = re.compile(ChangeLogEntry.date_line_regexp) + rolled_over_regexp = re.compile(ChangeLogEntry.rolled_over_regexp) + + # The first line should be a date line. + revision, first_line = ChangeLog._separate_revision_and_line(changelog_file.readline()) + assert(isinstance(first_line, unicode)) + if not date_line_regexp.match(ChangeLog.svn_blame_regexp.sub('', first_line)): + raise StopIteration + + entry_lines = [first_line] + revisions_in_entry = {revision: 1} if revision != None else None + for line in changelog_file: + if revisions_in_entry: + revision, line = ChangeLog._separate_revision_and_line(line) + + if rolled_over_regexp.match(line): + break + + if date_line_regexp.match(line): + most_probable_revision = max(revisions_in_entry, key=revisions_in_entry.__getitem__) if revisions_in_entry else None + # Remove the extra newline at the end + yield ChangeLogEntry(''.join(entry_lines[:-1]), revision=most_probable_revision) + entry_lines = [] + revisions_in_entry = {revision: 0} + + entry_lines.append(line) + if revisions_in_entry: + revisions_in_entry[revision] = revisions_in_entry.get(revision, 0) + 1 + + most_probable_revision = max(revisions_in_entry, key=revisions_in_entry.__getitem__) if revisions_in_entry else None + yield ChangeLogEntry(''.join(entry_lines[:-1]), revision=most_probable_revision) + + def latest_entry(self): + # ChangeLog files are always UTF-8, we read them in as such to support Reviewers with unicode in their names. + changelog_file = codecs.open(self.path, "r", "utf-8") + try: + return self.parse_latest_entry_from_file(changelog_file) + finally: + changelog_file.close() + + # _wrap_line and _wrap_lines exist to work around + # http://bugs.python.org/issue1859 + + def _wrap_line(self, line): + return textwrap.fill(line, + width=70, + initial_indent=self._changelog_indent, + # Don't break urls which may be longer than width. + break_long_words=False, + subsequent_indent=self._changelog_indent) + + # Workaround as suggested by guido in + # http://bugs.python.org/issue1859#msg60040 + + def _wrap_lines(self, message): + lines = [self._wrap_line(line) for line in message.splitlines()] + return "\n".join(lines) + + def update_with_unreviewed_message(self, message): + first_boilerplate_line_regexp = re.compile( + "%sNeed a short description and bug URL \(OOPS!\)" % self._changelog_indent) + removing_boilerplate = False + # inplace=1 creates a backup file and re-directs stdout to the file + for line in fileinput.FileInput(self.path, inplace=1): + if first_boilerplate_line_regexp.search(line): + message_lines = self._wrap_lines(message) + print first_boilerplate_line_regexp.sub(message_lines, line), + # Remove all the ChangeLog boilerplate before the first changed + # file. + removing_boilerplate = True + elif removing_boilerplate: + if line.find('*') >= 0: # each changed file is preceded by a * + removing_boilerplate = False + + if not removing_boilerplate: + print line, + + def set_reviewer(self, reviewer): + # inplace=1 creates a backup file and re-directs stdout to the file + for line in fileinput.FileInput(self.path, inplace=1): + # Trailing comma suppresses printing newline + print line.replace("NOBODY (OOPS!)", reviewer.encode("utf-8")), + + def set_short_description_and_bug_url(self, short_description, bug_url): + message = "%s\n %s" % (short_description, bug_url) + for line in fileinput.FileInput(self.path, inplace=1): + print line.replace("Need a short description and bug URL (OOPS!)", message.encode("utf-8")), diff --git a/Tools/Scripts/webkitpy/common/checkout/changelog_unittest.py b/Tools/Scripts/webkitpy/common/checkout/changelog_unittest.py new file mode 100644 index 000000000..0221bcee9 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/changelog_unittest.py @@ -0,0 +1,496 @@ +# Copyright (C) 2009 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from __future__ import with_statement + +import codecs +import os +import tempfile +import unittest + +from StringIO import StringIO + +from webkitpy.common.checkout.changelog import * + + +class ChangeLogTest(unittest.TestCase): + + _example_entry = u'''2009-08-17 Peter Kasting <pkasting@google.com> + + Reviewed by Tor Arne Vestb\xf8. + + https://bugs.webkit.org/show_bug.cgi?id=27323 + Only add Cygwin to the path when it isn't already there. This avoids + causing problems for people who purposefully have non-Cygwin versions of + executables like svn in front of the Cygwin ones in their paths. + + * DumpRenderTree/win/DumpRenderTree.vcproj: + * DumpRenderTree/win/ImageDiff.vcproj: + * DumpRenderTree/win/TestNetscapePlugin/TestNetscapePlugin.vcproj: +''' + + _rolled_over_footer = '== Rolled over to ChangeLog-2009-06-16 ==' + + # More example text than we need. Eventually we need to support parsing this all and write tests for the parsing. + _example_changelog = u"""2009-08-17 Tor Arne Vestb\xf8 <vestbo@webkit.org> + + <http://webkit.org/b/28393> check-webkit-style: add check for use of std::max()/std::min() instead of MAX()/MIN() + + Reviewed by David Levin. + + * Scripts/modules/cpp_style.py: + (_ERROR_CATEGORIES): Added 'runtime/max_min_macros'. + (check_max_min_macros): Added. Returns level 4 error when MAX() + and MIN() macros are used in header files and C++ source files. + (check_style): Added call to check_max_min_macros(). + * Scripts/modules/cpp_style_unittest.py: Added unit tests. + (test_max_macro): Added. + (test_min_macro): Added. + +2009-08-16 David Kilzer <ddkilzer@apple.com> + + Backed out r47343 which was mistakenly committed + + * Scripts/bugzilla-tool: + * Scripts/modules/scm.py: + +2009-06-18 Darin Adler <darin@apple.com> + + Rubber stamped by Mark Rowe. + + * DumpRenderTree/mac/DumpRenderTreeWindow.mm: + (-[DumpRenderTreeWindow close]): Resolved crashes seen during regression + tests. The close method can be called on a window that's already closed + so we can't assert here. + +2011-11-04 Benjamin Poulain <bpoulain@apple.com> + + [Mac] ResourceRequest's nsURLRequest() does not differentiate null and empty URLs with CFNetwork + https://bugs.webkit.org/show_bug.cgi?id=71539 + + Reviewed by David Kilzer. + + In order to have CFURL and NSURL to be consistent when both are used on Mac, + KURL::createCFURL() is changed to support empty URL values. + + * This change log entry is made up to test _parse_entry: + * a list of things + + * platform/cf/KURLCFNet.cpp: + (WebCore::createCFURLFromBuffer): + (WebCore::KURL::createCFURL): + * platform/mac/KURLMac.mm : + (WebCore::KURL::operator NSURL *): + (WebCore::KURL::createCFURL): + * WebCoreSupport/ChromeClientEfl.cpp: + (WebCore::ChromeClientEfl::closeWindowSoon): call new function and moves its + previous functionality there. + * ewk/ewk_private.h: + * ewk/ewk_view.cpp: + +2011-03-02 Carol Szabo <carol.szabo@nokia.com> + + Reviewed by David Hyatt <hyatt@apple.com> + + content property doesn't support quotes + https://bugs.webkit.org/show_bug.cgi?id=6503 + + Added full support for quotes as defined by CSS 2.1. + + Tests: fast/css/content/content-quotes-01.html + fast/css/content/content-quotes-02.html + fast/css/content/content-quotes-03.html + fast/css/content/content-quotes-04.html + fast/css/content/content-quotes-05.html + fast/css/content/content-quotes-06.html + +2011-03-31 Brent Fulgham <bfulgham@webkit.org> + + Reviewed Adam Roben. + + [WinCairo] Implement Missing drawWindowsBitmap method. + https://bugs.webkit.org/show_bug.cgi?id=57409 + +2011-03-28 Dirk Pranke <dpranke@chromium.org> + + RS=Tony Chang. + + r81977 moved FontPlatformData.h from + WebCore/platform/graphics/cocoa to platform/graphics. This + change updates the chromium build accordingly. + + https://bugs.webkit.org/show_bug.cgi?id=57281 + + * platform/graphics/chromium/CrossProcessFontLoading.mm: + +2011-05-04 Alexis Menard <alexis.menard@openbossa.org> + + Unreviewed warning fix. + + The variable is just used in the ASSERT macro. Let's use ASSERT_UNUSED to avoid + a warning in Release build. + + * accessibility/AccessibilityRenderObject.cpp: + (WebCore::lastChildConsideringContinuation): + +2011-10-11 Antti Koivisto <antti@apple.com> + + Resolve regular and visited link style in a single pass + https://bugs.webkit.org/show_bug.cgi?id=69838 + + Reviewed by Darin Adler + + We can simplify and speed up selector matching by removing the recursive matching done + to generate the style for the :visited pseudo selector. Both regular and visited link style + can be generated in a single pass through the style selector. + +== Rolled over to ChangeLog-2009-06-16 == +""" + + def test_parse_bug_id_from_changelog(self): + commit_text = ''' +2011-03-23 Ojan Vafai <ojan@chromium.org> + + Add failing result for WebKit2. All tests that require + focus fail on WebKit2. See https://bugs.webkit.org/show_bug.cgi?id=56988. + + * platform/mac-wk2/fast/css/pseudo-any-expected.txt: Added. + + ''' + + self.assertEquals(56988, parse_bug_id_from_changelog(commit_text)) + + commit_text = ''' +2011-03-23 Ojan Vafai <ojan@chromium.org> + + Add failing result for WebKit2. All tests that require + focus fail on WebKit2. See https://bugs.webkit.org/show_bug.cgi?id=56988. + https://bugs.webkit.org/show_bug.cgi?id=12345 + + * platform/mac-wk2/fast/css/pseudo-any-expected.txt: Added. + + ''' + + self.assertEquals(12345, parse_bug_id_from_changelog(commit_text)) + + commit_text = ''' +2011-03-31 Adam Roben <aroben@apple.com> + + Quote the executable path we pass to ::CreateProcessW + + This will ensure that spaces in the path will be interpreted correctly. + + Fixes <http://webkit.org/b/57569> Web process sometimes fails to launch when there are + spaces in its path + + Reviewed by Steve Falkenburg. + + * UIProcess/Launcher/win/ProcessLauncherWin.cpp: + (WebKit::ProcessLauncher::launchProcess): Surround the executable path in quotes. + + ''' + + self.assertEquals(57569, parse_bug_id_from_changelog(commit_text)) + + commit_text = ''' +2011-03-29 Timothy Hatcher <timothy@apple.com> + + Update WebCore Localizable.strings to contain WebCore, WebKit/mac and WebKit2 strings. + + https://webkit.org/b/57354 + + Reviewed by Sam Weinig. + + * English.lproj/Localizable.strings: Updated. + * StringsNotToBeLocalized.txt: Removed. To hard to maintain in WebCore. + * platform/network/cf/LoaderRunLoopCF.h: Remove a single quote in an #error so + extract-localizable-strings does not complain about unbalanced single quotes. + ''' + + self.assertEquals(57354, parse_bug_id_from_changelog(commit_text)) + + def test_parse_log_entries_from_changelog(self): + changelog_file = StringIO(self._example_changelog) + parsed_entries = list(ChangeLog.parse_entries_from_file(changelog_file)) + self.assertEquals(len(parsed_entries), 9) + self.assertEquals(parsed_entries[0].reviewer_text(), "David Levin") + self.assertEquals(parsed_entries[1].author_email(), "ddkilzer@apple.com") + self.assertEquals(parsed_entries[2].reviewer_text(), "Mark Rowe") + self.assertEquals(parsed_entries[2].touched_files(), ["DumpRenderTree/mac/DumpRenderTreeWindow.mm"]) + self.assertEquals(parsed_entries[3].author_name(), "Benjamin Poulain") + self.assertEquals(parsed_entries[3].touched_files(), ["platform/cf/KURLCFNet.cpp", "platform/mac/KURLMac.mm", + "WebCoreSupport/ChromeClientEfl.cpp", "ewk/ewk_private.h", "ewk/ewk_view.cpp"]) + self.assertEquals(parsed_entries[4].reviewer_text(), "David Hyatt") + self.assertEquals(parsed_entries[5].reviewer_text(), "Adam Roben") + self.assertEquals(parsed_entries[6].reviewer_text(), "Tony Chang") + self.assertEquals(parsed_entries[7].reviewer_text(), None) + self.assertEquals(parsed_entries[8].reviewer_text(), 'Darin Adler') + + def test_parse_log_entries_from_annotated_file(self): + changelog_file = StringIO(u'''100000 ossy@webkit.org 2011-11-11 Csaba Osztrogon\u00e1c <ossy@webkit.org> +100000 ossy@webkit.org +100000 ossy@webkit.org 100,000 !!! +100000 ossy@webkit.org +100000 ossy@webkit.org Reviewed by Zoltan Herczeg. +100000 ossy@webkit.org +100000 ossy@webkit.org * ChangeLog: Point out revision 100,000. +100000 ossy@webkit.org +93798 ap@apple.com 2011-08-25 Alexey Proskuryakov <ap@apple.com> +93798 ap@apple.com +93798 ap@apple.com Fix build when GCC 4.2 is not installed. +93798 ap@apple.com +93798 ap@apple.com * gtest/xcode/Config/CompilerVersion.xcconfig: Copied from Source/WebCore/Configurations/CompilerVersion.xcconfig. +93798 ap@apple.com * gtest/xcode/Config/General.xcconfig: +93798 ap@apple.com Use the same compiler version as other projects do. +93798 ap@apple.com +99491 andreas.kling@nokia.com 2011-11-03 Andreas Kling <kling@webkit.org> +99491 andreas.kling@nokia.com +99190 andreas.kling@nokia.com Unreviewed build fix, sigh. +99190 andreas.kling@nokia.com +99190 andreas.kling@nokia.com * css/CSSFontFaceRule.h: +99190 andreas.kling@nokia.com * css/CSSMutableStyleDeclaration.h: +99190 andreas.kling@nokia.com +99190 andreas.kling@nokia.com 2011-11-03 Andreas Kling <kling@webkit.org> +99190 andreas.kling@nokia.com +99187 andreas.kling@nokia.com Unreviewed build fix, out-of-line StyleSheet::parentStyleSheet() +99187 andreas.kling@nokia.com again since there's a cycle in the includes between CSSRule/StyleSheet. +99187 andreas.kling@nokia.com +99187 andreas.kling@nokia.com * css/StyleSheet.cpp: +99187 andreas.kling@nokia.com (WebCore::StyleSheet::parentStyleSheet): +99187 andreas.kling@nokia.com * css/StyleSheet.h: +99187 andreas.kling@nokia.com +''') + parsed_entries = list(ChangeLog.parse_entries_from_file(changelog_file)) + self.assertEquals(parsed_entries[0].revision(), 100000) + self.assertEquals(parsed_entries[0].reviewer_text(), "Zoltan Herczeg") + self.assertEquals(parsed_entries[0].author_name(), u"Csaba Osztrogon\u00e1c") + self.assertEquals(parsed_entries[0].author_email(), "ossy@webkit.org") + self.assertEquals(parsed_entries[1].revision(), 93798) + self.assertEquals(parsed_entries[1].author_name(), "Alexey Proskuryakov") + self.assertEquals(parsed_entries[2].revision(), 99190) + self.assertEquals(parsed_entries[2].author_name(), "Andreas Kling") + self.assertEquals(parsed_entries[3].revision(), 99187) + self.assertEquals(parsed_entries[3].author_name(), "Andreas Kling") + + def _assert_parse_reviewer_text_and_list(self, text, expected_reviewer_text, expected_reviewer_text_list=None): + reviewer_text, reviewer_text_list = ChangeLogEntry._parse_reviewer_text(text) + self.assertEquals(reviewer_text, expected_reviewer_text) + if expected_reviewer_text_list: + self.assertEquals(reviewer_text_list, expected_reviewer_text_list) + else: + self.assertEquals(reviewer_text_list, [expected_reviewer_text]) + + def _assert_parse_reviewer_text_list(self, text, expected_reviewer_text_list): + reviewer_text, reviewer_text_list = ChangeLogEntry._parse_reviewer_text(text) + self.assertEquals(reviewer_text_list, expected_reviewer_text_list) + + def test_parse_reviewer_text(self): + self._assert_parse_reviewer_text_and_list(' reviewed by Ryosuke Niwa, Oliver Hunt, and Dimitri Glazkov', + 'Ryosuke Niwa, Oliver Hunt, and Dimitri Glazkov', ['Ryosuke Niwa', 'Oliver Hunt', 'Dimitri Glazkov']) + self._assert_parse_reviewer_text_and_list('Reviewed by Brady Eidson and David Levin, landed by Brady Eidson', + 'Brady Eidson and David Levin', ['Brady Eidson', 'David Levin']) + + self._assert_parse_reviewer_text_and_list('Reviewed by Simon Fraser. Committed by Beth Dakin.', 'Simon Fraser') + self._assert_parse_reviewer_text_and_list('Reviewed by Geoff Garen. V8 fixes courtesy of Dmitry Titov.', 'Geoff Garen') + self._assert_parse_reviewer_text_and_list('Reviewed by Adam Roben&Dirk Schulze', 'Adam Roben&Dirk Schulze', ['Adam Roben', 'Dirk Schulze']) + self._assert_parse_reviewer_text_and_list('Rubber stamps by Darin Adler & Sam Weinig.', 'Darin Adler & Sam Weinig', ['Darin Adler', 'Sam Weinig']) + + self._assert_parse_reviewer_text_and_list('Reviewed by adam,andy and andy adam, andy smith', + 'adam,andy and andy adam, andy smith', ['adam', 'andy', 'andy adam', 'andy smith']) + + self._assert_parse_reviewer_text_and_list('rubber stamped by Oliver Hunt (oliver@apple.com) and Darin Adler (darin@apple.com)', + 'Oliver Hunt and Darin Adler', ['Oliver Hunt', 'Darin Adler']) + + self._assert_parse_reviewer_text_and_list('rubber Stamped by David Hyatt <hyatt@apple.com>', 'David Hyatt') + self._assert_parse_reviewer_text_and_list('Rubber-stamped by Antti Koivisto.', 'Antti Koivisto') + self._assert_parse_reviewer_text_and_list('Rubberstamped by Dan Bernstein.', 'Dan Bernstein') + self._assert_parse_reviewer_text_and_list('Reviews by Ryosuke Niwa', 'Ryosuke Niwa') + self._assert_parse_reviewer_text_and_list('Reviews Ryosuke Niwa', 'Ryosuke Niwa') + self._assert_parse_reviewer_text_and_list('Rubberstamp Ryosuke Niwa', 'Ryosuke Niwa') + self._assert_parse_reviewer_text_and_list('Typed and reviewed by Alexey Proskuryakov.', 'Alexey Proskuryakov') + self._assert_parse_reviewer_text_and_list('Reviewed and landed by Brady Eidson', 'Brady Eidson') + self._assert_parse_reviewer_text_and_list('Reviewed by rniwa@webkit.org.', 'rniwa@webkit.org') + self._assert_parse_reviewer_text_and_list('Reviewed by Dirk Schulze / Darin Adler.', 'Dirk Schulze / Darin Adler', ['Dirk Schulze', 'Darin Adler']) + self._assert_parse_reviewer_text_and_list('Reviewed by Sam Weinig + Oliver Hunt.', 'Sam Weinig + Oliver Hunt', ['Sam Weinig', 'Oliver Hunt']) + + self._assert_parse_reviewer_text_list('Reviewed by Sam Weinig, and given a good once-over by Jeff Miller.', ['Sam Weinig', 'Jeff Miller']) + self._assert_parse_reviewer_text_list(' Reviewed by Sam Weinig, even though this is just a...', ['Sam Weinig']) + self._assert_parse_reviewer_text_list('Rubber stamped by by Gustavo Noronha Silva', ['Gustavo Noronha Silva']) + self._assert_parse_reviewer_text_list('Rubberstamped by Noam Rosenthal, who wrote the original code.', ['Noam Rosenthal']) + self._assert_parse_reviewer_text_list('Reviewed by Dan Bernstein (relanding of r47157)', ['Dan Bernstein']) + self._assert_parse_reviewer_text_list('Reviewed by Geoffrey "Sean/Shawn/Shaun" Garen', ['Geoffrey Garen']) + self._assert_parse_reviewer_text_list('Reviewed by Dave "Messy" Hyatt.', ['Dave Hyatt']) + self._assert_parse_reviewer_text_list('Reviewed by Sam \'The Belly\' Weinig', ['Sam Weinig']) + self._assert_parse_reviewer_text_list('Rubber-stamped by David "I\'d prefer not" Hyatt.', ['David Hyatt']) + self._assert_parse_reviewer_text_list('Reviewed by Mr. Geoffrey Garen.', ['Geoffrey Garen']) + self._assert_parse_reviewer_text_list('Reviewed by Darin (ages ago)', ['Darin']) + self._assert_parse_reviewer_text_list('Reviewed by Sam Weinig (except for a few comment and header tweaks).', ['Sam Weinig']) + self._assert_parse_reviewer_text_list('Reviewed by Sam Weinig (all but the FormDataListItem rename)', ['Sam Weinig']) + self._assert_parse_reviewer_text_list('Reviewed by Darin Adler, tweaked and landed by Beth.', ['Darin Adler']) + self._assert_parse_reviewer_text_list('Reviewed by Sam Weinig with no hesitation', ['Sam Weinig']) + self._assert_parse_reviewer_text_list('Reviewed by Oliver Hunt, okayed by Darin Adler.', ['Oliver Hunt']) + self._assert_parse_reviewer_text_list('Reviewed by Darin Adler).', ['Darin Adler']) + + # For now, we let unofficial reviewers recognized as reviewers + self._assert_parse_reviewer_text_list('Reviewed by Sam Weinig, Anders Carlsson, and (unofficially) Adam Barth.', + ['Sam Weinig', 'Anders Carlsson', 'Adam Barth']) + + self._assert_parse_reviewer_text_list('Reviewed by NOBODY.', None) + self._assert_parse_reviewer_text_list('Reviewed by NOBODY - Build Fix.', None) + self._assert_parse_reviewer_text_list('Reviewed by NOBODY, layout tests fix.', None) + self._assert_parse_reviewer_text_list('Reviewed by NOBODY (Qt build fix pt 2).', None) + self._assert_parse_reviewer_text_list('Reviewed by NOBODY(rollout)', None) + self._assert_parse_reviewer_text_list('Reviewed by NOBODY (Build fix, forgot to svn add this file)', None) + self._assert_parse_reviewer_text_list('Reviewed by nobody (trivial follow up fix), Joseph Pecoraro LGTM-ed.', None) + + def _entry_with_author(self, author_text): + return ChangeLogEntry('''2009-08-19 AUTHOR_TEXT + + Reviewed by Ryosuke Niwa + + * Scripts/bugzilla-tool: +'''.replace("AUTHOR_TEXT", author_text)) + + def _entry_with_reviewer(self, reviewer_line): + return ChangeLogEntry('''2009-08-19 Eric Seidel <eric@webkit.org> + + REVIEW_LINE + + * Scripts/bugzilla-tool: +'''.replace("REVIEW_LINE", reviewer_line)) + + def _contributors(self, names): + return [CommitterList().contributor_by_name(name) for name in names] + + def _assert_fuzzy_reviewer_match(self, reviewer_text, expected_text_list, expected_contributors): + unused, reviewer_text_list = ChangeLogEntry._parse_reviewer_text(reviewer_text) + self.assertEquals(reviewer_text_list, expected_text_list) + self.assertEquals(self._entry_with_reviewer(reviewer_text).reviewers(), self._contributors(expected_contributors)) + + def test_fuzzy_reviewer_match(self): + self._assert_fuzzy_reviewer_match('Reviewed by Dimitri Glazkov, build fix', ['Dimitri Glazkov', 'build fix'], ['Dimitri Glazkov']) + self._assert_fuzzy_reviewer_match('Reviewed by BUILD FIX', ['BUILD FIX'], []) + self._assert_fuzzy_reviewer_match('Reviewed by Mac build fix', ['Mac build fix'], []) + self._assert_fuzzy_reviewer_match('Reviewed by Darin Adler, Dan Bernstein, Adele Peterson, and others.', + ['Darin Adler', 'Dan Bernstein', 'Adele Peterson', 'others'], ['Darin Adler', 'Dan Bernstein', 'Adele Peterson']) + self._assert_fuzzy_reviewer_match('Reviewed by George Staikos (and others)', ['George Staikos', 'others'], ['George Staikos']) + self._assert_fuzzy_reviewer_match('Reviewed by Mark Rowe, but Dan Bernstein also reviewed and asked thoughtful questions.', + ['Mark Rowe', 'but Dan Bernstein also reviewed', 'asked thoughtful questions'], ['Mark Rowe']) + self._assert_fuzzy_reviewer_match('Reviewed by Darin Adler in <https://bugs.webkit.org/show_bug.cgi?id=47736>.', ['Darin Adler in'], ['Darin Adler']) + self._assert_fuzzy_reviewer_match('Reviewed by Adam Barth.:w', ['Adam Barth.:w'], ['Adam Barth']) + + def _assert_parse_authors(self, author_text, expected_contributors): + parsed_authors = [(author['name'], author['email']) for author in self._entry_with_author(author_text).authors()] + self.assertEquals(parsed_authors, expected_contributors) + + def test_parse_authors(self): + self._assert_parse_authors(u'Aaron Colwell <acolwell@chromium.org>', [(u'Aaron Colwell', u'acolwell@chromium.org')]) + self._assert_parse_authors('Eric Seidel <eric@webkit.org>, Ryosuke Niwa <rniwa@webkit.org>', + [('Eric Seidel', 'eric@webkit.org'), ('Ryosuke Niwa', 'rniwa@webkit.org')]) + self._assert_parse_authors('Zan Dobersek <zandobersek@gmail.com> and Philippe Normand <pnormand@igalia.com>', + [('Zan Dobersek', 'zandobersek@gmail.com'), ('Philippe Normand', 'pnormand@igalia.com')]) + self._assert_parse_authors('New Contributor <new@webkit.org> and Noob <noob@webkit.org>', + [('New Contributor', 'new@webkit.org'), ('Noob', 'noob@webkit.org')]) + + def _assert_has_valid_reviewer(self, reviewer_line, expected): + self.assertEqual(self._entry_with_reviewer(reviewer_line).has_valid_reviewer(), expected) + + def test_has_valid_reviewer(self): + self._assert_has_valid_reviewer("Reviewed by Eric Seidel.", True) + self._assert_has_valid_reviewer("Reviewed by Eric Seidel", True) # Not picky about the '.' + self._assert_has_valid_reviewer("Reviewed by Eric.", False) + self._assert_has_valid_reviewer("Reviewed by Eric C Seidel.", False) + self._assert_has_valid_reviewer("Rubber-stamped by Eric.", False) + self._assert_has_valid_reviewer("Rubber-stamped by Eric Seidel.", True) + self._assert_has_valid_reviewer("Rubber stamped by Eric.", False) + self._assert_has_valid_reviewer("Rubber stamped by Eric Seidel.", True) + self._assert_has_valid_reviewer("Unreviewed build fix.", True) + + def test_latest_entry_parse(self): + changelog_contents = u"%s\n%s" % (self._example_entry, self._example_changelog) + changelog_file = StringIO(changelog_contents) + latest_entry = ChangeLog.parse_latest_entry_from_file(changelog_file) + self.assertEquals(latest_entry.contents(), self._example_entry) + self.assertEquals(latest_entry.author_name(), "Peter Kasting") + self.assertEquals(latest_entry.author_email(), "pkasting@google.com") + self.assertEquals(latest_entry.reviewer_text(), u"Tor Arne Vestb\xf8") + self.assertEquals(latest_entry.touched_files(), ["DumpRenderTree/win/DumpRenderTree.vcproj", "DumpRenderTree/win/ImageDiff.vcproj", "DumpRenderTree/win/TestNetscapePlugin/TestNetscapePlugin.vcproj"]) + + self.assertTrue(latest_entry.reviewer()) # Make sure that our UTF8-based lookup of Tor works. + + def test_latest_entry_parse_single_entry(self): + changelog_contents = u"%s\n%s" % (self._example_entry, self._rolled_over_footer) + changelog_file = StringIO(changelog_contents) + latest_entry = ChangeLog.parse_latest_entry_from_file(changelog_file) + self.assertEquals(latest_entry.contents(), self._example_entry) + self.assertEquals(latest_entry.author_name(), "Peter Kasting") + + @staticmethod + def _write_tmp_file_with_contents(byte_array): + assert(isinstance(byte_array, str)) + (file_descriptor, file_path) = tempfile.mkstemp() # NamedTemporaryFile always deletes the file on close in python < 2.6 + with os.fdopen(file_descriptor, "w") as file: + file.write(byte_array) + return file_path + + @staticmethod + def _read_file_contents(file_path, encoding): + with codecs.open(file_path, "r", encoding) as file: + return file.read() + + # FIXME: We really should be getting this from prepare-ChangeLog itself. + _new_entry_boilerplate = '''2009-08-19 Eric Seidel <eric@webkit.org> + + Need a short description and bug URL (OOPS!) + + Reviewed by NOBODY (OOPS!). + + * Scripts/bugzilla-tool: +''' + + def test_set_reviewer(self): + changelog_contents = u"%s\n%s" % (self._new_entry_boilerplate, self._example_changelog) + changelog_path = self._write_tmp_file_with_contents(changelog_contents.encode("utf-8")) + reviewer_name = 'Test Reviewer' + ChangeLog(changelog_path).set_reviewer(reviewer_name) + actual_contents = self._read_file_contents(changelog_path, "utf-8") + expected_contents = changelog_contents.replace('NOBODY (OOPS!)', reviewer_name) + os.remove(changelog_path) + self.assertEquals(actual_contents.splitlines(), expected_contents.splitlines()) + + def test_set_short_description_and_bug_url(self): + changelog_contents = u"%s\n%s" % (self._new_entry_boilerplate, self._example_changelog) + changelog_path = self._write_tmp_file_with_contents(changelog_contents.encode("utf-8")) + short_description = "A short description" + bug_url = "http://example.com/b/2344" + ChangeLog(changelog_path).set_short_description_and_bug_url(short_description, bug_url) + actual_contents = self._read_file_contents(changelog_path, "utf-8") + expected_message = "%s\n %s" % (short_description, bug_url) + expected_contents = changelog_contents.replace("Need a short description and bug URL (OOPS!)", expected_message) + os.remove(changelog_path) + self.assertEquals(actual_contents.splitlines(), expected_contents.splitlines()) diff --git a/Tools/Scripts/webkitpy/common/checkout/checkout.py b/Tools/Scripts/webkitpy/common/checkout/checkout.py new file mode 100644 index 000000000..08abe6cd8 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/checkout.py @@ -0,0 +1,179 @@ +# Copyright (c) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import StringIO + +from webkitpy.common.config import urls +from webkitpy.common.checkout.changelog import ChangeLog, parse_bug_id_from_changelog +from webkitpy.common.checkout.commitinfo import CommitInfo +from webkitpy.common.checkout.scm import CommitMessage +from webkitpy.common.checkout.deps import DEPS +from webkitpy.common.memoized import memoized +from webkitpy.common.system.executive import ScriptError +from webkitpy.common.system.deprecated_logging import log + + +# This class represents the WebKit-specific parts of the checkout (like ChangeLogs). +# FIXME: Move a bunch of ChangeLog-specific processing from SCM to this object. +# NOTE: All paths returned from this class should be absolute. +class Checkout(object): + def __init__(self, scm, executive=None, filesystem=None): + self._scm = scm + # FIXME: We shouldn't be grabbing at private members on scm. + self._executive = executive or self._scm._executive + self._filesystem = filesystem or self._scm._filesystem + + def is_path_to_changelog(self, path): + return self._filesystem.basename(path) == "ChangeLog" + + def _latest_entry_for_changelog_at_revision(self, changelog_path, revision): + changelog_contents = self._scm.contents_at_revision(changelog_path, revision) + # contents_at_revision returns a byte array (str()), but we know + # that ChangeLog files are utf-8. parse_latest_entry_from_file + # expects a file-like object which vends unicode(), so we decode here. + # Old revisions of Sources/WebKit/wx/ChangeLog have some invalid utf8 characters. + changelog_file = StringIO.StringIO(changelog_contents.decode("utf-8", "ignore")) + return ChangeLog.parse_latest_entry_from_file(changelog_file) + + def changelog_entries_for_revision(self, revision, changed_files=None): + if not changed_files: + changed_files = self._scm.changed_files_for_revision(revision) + # FIXME: This gets confused if ChangeLog files are moved, as + # deletes are still "changed files" per changed_files_for_revision. + # FIXME: For now we hack around this by caching any exceptions + # which result from having deleted files included the changed_files list. + changelog_entries = [] + for path in changed_files: + if not self.is_path_to_changelog(path): + continue + try: + changelog_entries.append(self._latest_entry_for_changelog_at_revision(path, revision)) + except ScriptError: + pass + return changelog_entries + + def _changelog_data_for_revision(self, revision): + changed_files = self._scm.changed_files_for_revision(revision) + changelog_entries = self.changelog_entries_for_revision(revision, changed_files=changed_files) + # Assume for now that the first entry has everything we need: + # FIXME: This will throw an exception if there were no ChangeLogs. + if not len(changelog_entries): + return None + changelog_entry = changelog_entries[0] + return { + "bug_id": parse_bug_id_from_changelog(changelog_entry.contents()), + "author_name": changelog_entry.author_name(), + "author_email": changelog_entry.author_email(), + "author": changelog_entry.author(), + "reviewer_text": changelog_entry.reviewer_text(), + "reviewer": changelog_entry.reviewer(), + "contents": changelog_entry.contents(), + "changed_files": changed_files, + } + + @memoized + def commit_info_for_revision(self, revision): + committer_email = self._scm.committer_email_for_revision(revision) + changelog_data = self._changelog_data_for_revision(revision) + if not changelog_data: + return None + return CommitInfo(revision, committer_email, changelog_data) + + def bug_id_for_revision(self, revision): + return self.commit_info_for_revision(revision).bug_id() + + def _modified_files_matching_predicate(self, git_commit, predicate, changed_files=None): + # SCM returns paths relative to scm.checkout_root + # Callers (especially those using the ChangeLog class) may + # expect absolute paths, so this method returns absolute paths. + if not changed_files: + changed_files = self._scm.changed_files(git_commit) + return filter(predicate, map(self._scm.absolute_path, changed_files)) + + def modified_changelogs(self, git_commit, changed_files=None): + return self._modified_files_matching_predicate(git_commit, self.is_path_to_changelog, changed_files=changed_files) + + def modified_non_changelogs(self, git_commit, changed_files=None): + return self._modified_files_matching_predicate(git_commit, lambda path: not self.is_path_to_changelog(path), changed_files=changed_files) + + def commit_message_for_this_commit(self, git_commit, changed_files=None): + changelog_paths = self.modified_changelogs(git_commit, changed_files) + if not len(changelog_paths): + raise ScriptError(message="Found no modified ChangeLogs, cannot create a commit message.\n" + "All changes require a ChangeLog. See:\n %s" % urls.contribution_guidelines) + + message_text = self._scm.run([self._scm.script_path('commit-log-editor'), '--print-log'] + changelog_paths, return_stderr=False) + return CommitMessage(message_text.splitlines()) + + def recent_commit_infos_for_files(self, paths): + revisions = set(sum(map(self._scm.revisions_changing_file, paths), [])) + return set(map(self.commit_info_for_revision, revisions)) + + def suggested_reviewers(self, git_commit, changed_files=None): + changed_files = self.modified_non_changelogs(git_commit, changed_files) + commit_infos = self.recent_commit_infos_for_files(changed_files) + reviewers = [commit_info.reviewer() for commit_info in commit_infos if commit_info.reviewer()] + reviewers.extend([commit_info.author() for commit_info in commit_infos if commit_info.author() and commit_info.author().can_review]) + return sorted(set(reviewers)) + + def bug_id_for_this_commit(self, git_commit, changed_files=None): + try: + return parse_bug_id_from_changelog(self.commit_message_for_this_commit(git_commit, changed_files).message()) + except ScriptError, e: + pass # We might not have ChangeLogs. + + def chromium_deps(self): + return DEPS(self._scm.absolute_path(self._filesystem.join("Source", "WebKit", "chromium", "DEPS"))) + + def apply_patch(self, patch, force=False): + # It's possible that the patch was not made from the root directory. + # We should detect and handle that case. + # FIXME: Move _scm.script_path here once we get rid of all the dependencies. + args = [self._scm.script_path('svn-apply')] + if patch.reviewer(): + args += ['--reviewer', patch.reviewer().full_name] + if force: + args.append('--force') + self._executive.run_command(args, input=patch.contents()) + + def apply_reverse_diff(self, revision): + self._scm.apply_reverse_diff(revision) + + # We revert the ChangeLogs because removing lines from a ChangeLog + # doesn't make sense. ChangeLogs are append only. + changelog_paths = self.modified_changelogs(git_commit=None) + if len(changelog_paths): + self._scm.revert_files(changelog_paths) + + conflicts = self._scm.conflicted_files() + if len(conflicts): + raise ScriptError(message="Failed to apply reverse diff for revision %s because of the following conflicts:\n%s" % (revision, "\n".join(conflicts))) + + def apply_reverse_diffs(self, revision_list): + for revision in sorted(revision_list, reverse=True): + self.apply_reverse_diff(revision) diff --git a/Tools/Scripts/webkitpy/common/checkout/checkout_mock.py b/Tools/Scripts/webkitpy/common/checkout/checkout_mock.py new file mode 100644 index 000000000..039db1f6a --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/checkout_mock.py @@ -0,0 +1,95 @@ +# Copyright (C) 2011 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import os + +from .deps_mock import MockDEPS +from .commitinfo import CommitInfo + +# FIXME: These imports are wrong, we should use a shared MockCommittersList. +from webkitpy.common.config.committers import CommitterList +from webkitpy.common.net.bugzilla.bugzilla_mock import _mock_reviewers + + +class MockCommitMessage(object): + def message(self): + return "This is a fake commit message that is at least 50 characters." + + +class MockCheckout(object): + + # FIXME: This should move onto the Host object, and we should use a MockCommitterList for tests. + _committer_list = CommitterList() + + def commit_info_for_revision(self, svn_revision): + # The real Checkout would probably throw an exception, but this is the only way tests have to get None back at the moment. + if not svn_revision: + return None + return CommitInfo(svn_revision, "eric@webkit.org", { + "bug_id": 50000, + "author_name": "Adam Barth", + "author_email": "abarth@webkit.org", + "author": self._committer_list.contributor_by_email("abarth@webkit.org"), + "reviewer_text": "Darin Adler", + "reviewer": self._committer_list.committer_by_name("Darin Adler"), + "changed_files": [ + "path/to/file", + "another/file", + ], + }) + + def is_path_to_changelog(self, path): + # FIXME: This should self._filesystem.basename. + return os.path.basename(path) == "ChangeLog" + + def bug_id_for_revision(self, svn_revision): + return 12345 + + def recent_commit_infos_for_files(self, paths): + return [self.commit_info_for_revision(32)] + + def modified_changelogs(self, git_commit, changed_files=None): + # Ideally we'd return something more interesting here. The problem is + # that LandDiff will try to actually read the patch from disk! + return [] + + def commit_message_for_this_commit(self, git_commit, changed_files=None): + return MockCommitMessage() + + def chromium_deps(self): + return MockDEPS() + + def apply_patch(self, patch, force=False): + pass + + def apply_reverse_diffs(self, revision): + pass + + def suggested_reviewers(self, git_commit, changed_files=None): + # FIXME: We should use a shared mock commiter list. + return [_mock_reviewers[0]] diff --git a/Tools/Scripts/webkitpy/common/checkout/checkout_unittest.py b/Tools/Scripts/webkitpy/common/checkout/checkout_unittest.py new file mode 100644 index 000000000..962364b6c --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/checkout_unittest.py @@ -0,0 +1,252 @@ +# Copyright (C) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from __future__ import with_statement + +import codecs +import os +import shutil +import tempfile +import unittest + +from .checkout import Checkout +from .changelog import ChangeLogEntry +from .scm import CommitMessage, SCMDetector +from .scm.scm_mock import MockSCM +from webkitpy.common.system.executive import Executive, ScriptError +from webkitpy.common.system.filesystem import FileSystem # FIXME: This should not be needed. +from webkitpy.common.system.filesystem_mock import MockFileSystem +from webkitpy.common.system.executive_mock import MockExecutive +from webkitpy.thirdparty.mock import Mock + + +_changelog1entry1 = u"""2010-03-25 Tor Arne Vestb\u00f8 <vestbo@webkit.org> + + Unreviewed build fix to un-break webkit-patch land. + + Move commit_message_for_this_commit from scm to checkout + https://bugs.webkit.org/show_bug.cgi?id=36629 + + * Scripts/webkitpy/common/checkout/api.py: import scm.CommitMessage +""" +_changelog1entry2 = u"""2010-03-25 Adam Barth <abarth@webkit.org> + + Reviewed by Eric Seidel. + + Move commit_message_for_this_commit from scm to checkout + https://bugs.webkit.org/show_bug.cgi?id=36629 + + * Scripts/webkitpy/common/checkout/api.py: +""" +_changelog1 = u"\n".join([_changelog1entry1, _changelog1entry2]) +_changelog2 = u"""2010-03-25 Tor Arne Vestb\u00f8 <vestbo@webkit.org> + + Unreviewed build fix to un-break webkit-patch land. + + Second part of this complicated change by me, Tor Arne Vestb\u00f8! + + * Path/To/Complicated/File: Added. + +2010-03-25 Adam Barth <abarth@webkit.org> + + Reviewed by Eric Seidel. + + Filler change. +""" + +class CommitMessageForThisCommitTest(unittest.TestCase): + expected_commit_message = u"""Unreviewed build fix to un-break webkit-patch land. + +Tools: + +Move commit_message_for_this_commit from scm to checkout +https://bugs.webkit.org/show_bug.cgi?id=36629 + +* Scripts/webkitpy/common/checkout/api.py: import scm.CommitMessage + +LayoutTests: + +Second part of this complicated change by me, Tor Arne Vestb\u00f8! + +* Path/To/Complicated/File: Added. +""" + + def setUp(self): + # FIXME: This should not need to touch the filesystem, however + # ChangeLog is difficult to mock at current. + self.filesystem = FileSystem() + self.temp_dir = str(self.filesystem.mkdtemp(suffix="changelogs")) + self.old_cwd = self.filesystem.getcwd() + self.filesystem.chdir(self.temp_dir) + + # Trick commit-log-editor into thinking we're in a Subversion working copy so it won't + # complain about not being able to figure out what SCM is in use. + # FIXME: VCSTools.pm is no longer so easily fooled. It logs because "svn info" doesn't + # treat a bare .svn directory being part of an svn checkout. + self.filesystem.maybe_make_directory(".svn") + + self.changelogs = map(self.filesystem.abspath, (self.filesystem.join("Tools", "ChangeLog"), self.filesystem.join("LayoutTests", "ChangeLog"))) + for path, contents in zip(self.changelogs, (_changelog1, _changelog2)): + self.filesystem.maybe_make_directory(self.filesystem.dirname(path)) + self.filesystem.write_text_file(path, contents) + + def tearDown(self): + self.filesystem.rmtree(self.temp_dir) + self.filesystem.chdir(self.old_cwd) + + def test_commit_message_for_this_commit(self): + executive = Executive() + + def mock_run(*args, **kwargs): + # Note that we use a real Executive here, not a MockExecutive, so we can test that we're + # invoking commit-log-editor correctly. + env = os.environ.copy() + env['CHANGE_LOG_EMAIL_ADDRESS'] = 'vestbo@webkit.org' + kwargs['env'] = env + return executive.run_command(*args, **kwargs) + + detector = SCMDetector(self.filesystem, executive) + real_scm = detector.detect_scm_system(self.old_cwd) + + mock_scm = MockSCM() + mock_scm.run = mock_run + mock_scm.script_path = real_scm.script_path + + checkout = Checkout(mock_scm) + checkout.modified_changelogs = lambda git_commit, changed_files=None: self.changelogs + commit_message = checkout.commit_message_for_this_commit(git_commit=None) + self.assertEqual(commit_message.message(), self.expected_commit_message) + + +class CheckoutTest(unittest.TestCase): + def _make_checkout(self): + return Checkout(scm=MockSCM(), filesystem=MockFileSystem(), executive=MockExecutive()) + + def test_latest_entry_for_changelog_at_revision(self): + def mock_contents_at_revision(changelog_path, revision): + self.assertEqual(changelog_path, "foo") + self.assertEqual(revision, "bar") + # contents_at_revision is expected to return a byte array (str) + # so we encode our unicode ChangeLog down to a utf-8 stream. + # The ChangeLog utf-8 decoding should ignore invalid codepoints. + invalid_utf8 = "\255" + return _changelog1.encode("utf-8") + invalid_utf8 + checkout = self._make_checkout() + checkout._scm.contents_at_revision = mock_contents_at_revision + entry = checkout._latest_entry_for_changelog_at_revision("foo", "bar") + self.assertEqual(entry.contents(), _changelog1entry1) + + # FIXME: This tests a hack around our current changed_files handling. + # Right now changelog_entries_for_revision tries to fetch deleted files + # from revisions, resulting in a ScriptError exception. Test that we + # recover from those and still return the other ChangeLog entries. + def test_changelog_entries_for_revision(self): + checkout = self._make_checkout() + checkout._scm.changed_files_for_revision = lambda revision: ['foo/ChangeLog', 'bar/ChangeLog'] + + def mock_latest_entry_for_changelog_at_revision(path, revision): + if path == "foo/ChangeLog": + return 'foo' + raise ScriptError() + + checkout._latest_entry_for_changelog_at_revision = mock_latest_entry_for_changelog_at_revision + + # Even though fetching one of the entries failed, the other should succeed. + entries = checkout.changelog_entries_for_revision(1) + self.assertEqual(len(entries), 1) + self.assertEqual(entries[0], 'foo') + + def test_commit_info_for_revision(self): + checkout = self._make_checkout() + checkout._scm.changed_files_for_revision = lambda revision: ['path/to/file', 'another/file'] + checkout._scm.committer_email_for_revision = lambda revision, changed_files=None: "committer@example.com" + checkout.changelog_entries_for_revision = lambda revision, changed_files=None: [ChangeLogEntry(_changelog1entry1)] + commitinfo = checkout.commit_info_for_revision(4) + self.assertEqual(commitinfo.bug_id(), 36629) + self.assertEqual(commitinfo.author_name(), u"Tor Arne Vestb\u00f8") + self.assertEqual(commitinfo.author_email(), "vestbo@webkit.org") + self.assertEqual(commitinfo.reviewer_text(), None) + self.assertEqual(commitinfo.reviewer(), None) + self.assertEqual(commitinfo.committer_email(), "committer@example.com") + self.assertEqual(commitinfo.committer(), None) + self.assertEqual(commitinfo.to_json(), { + 'bug_id': 36629, + 'author_email': 'vestbo@webkit.org', + 'changed_files': [ + 'path/to/file', + 'another/file', + ], + 'reviewer_text': None, + 'author_name': u'Tor Arne Vestb\xf8', + }) + + checkout.changelog_entries_for_revision = lambda revision, changed_files=None: [] + self.assertEqual(checkout.commit_info_for_revision(1), None) + + def test_bug_id_for_revision(self): + checkout = self._make_checkout() + checkout._scm.committer_email_for_revision = lambda revision: "committer@example.com" + checkout.changelog_entries_for_revision = lambda revision, changed_files=None: [ChangeLogEntry(_changelog1entry1)] + self.assertEqual(checkout.bug_id_for_revision(4), 36629) + + def test_bug_id_for_this_commit(self): + checkout = self._make_checkout() + checkout.commit_message_for_this_commit = lambda git_commit, changed_files=None: CommitMessage(ChangeLogEntry(_changelog1entry1).contents().splitlines()) + self.assertEqual(checkout.bug_id_for_this_commit(git_commit=None), 36629) + + def test_modified_changelogs(self): + checkout = self._make_checkout() + checkout._scm.checkout_root = "/foo/bar" + checkout._scm.changed_files = lambda git_commit: ["file1", "ChangeLog", "relative/path/ChangeLog"] + expected_changlogs = ["/foo/bar/ChangeLog", "/foo/bar/relative/path/ChangeLog"] + self.assertEqual(checkout.modified_changelogs(git_commit=None), expected_changlogs) + + def test_suggested_reviewers(self): + def mock_changelog_entries_for_revision(revision, changed_files=None): + if revision % 2 == 0: + return [ChangeLogEntry(_changelog1entry1)] + return [ChangeLogEntry(_changelog1entry2)] + + def mock_revisions_changing_file(path, limit=5): + if path.endswith("ChangeLog"): + return [3] + return [4, 8] + + checkout = self._make_checkout() + checkout._scm.checkout_root = "/foo/bar" + checkout._scm.changed_files = lambda git_commit: ["file1", "file2", "relative/path/ChangeLog"] + checkout._scm.revisions_changing_file = mock_revisions_changing_file + checkout.changelog_entries_for_revision = mock_changelog_entries_for_revision + reviewers = checkout.suggested_reviewers(git_commit=None) + reviewer_names = [reviewer.full_name for reviewer in reviewers] + self.assertEqual(reviewer_names, [u'Tor Arne Vestb\xf8']) + + def test_chromium_deps(self): + checkout = self._make_checkout() + checkout._scm.checkout_root = "/foo/bar" + self.assertEqual(checkout.chromium_deps()._path, '/foo/bar/Source/WebKit/chromium/DEPS') diff --git a/Tools/Scripts/webkitpy/common/checkout/commitinfo.py b/Tools/Scripts/webkitpy/common/checkout/commitinfo.py new file mode 100644 index 000000000..cba3fdd64 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/commitinfo.py @@ -0,0 +1,100 @@ +# Copyright (c) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# WebKit's python module for holding information on a commit + +from webkitpy.common.config import urls +from webkitpy.common.config.committers import CommitterList + + +class CommitInfo(object): + def __init__(self, revision, committer_email, changelog_data, committer_list=CommitterList()): + self._revision = revision + self._committer_email = committer_email + self._changelog_data = changelog_data + + # Derived values: + self._committer = committer_list.committer_by_email(committer_email) + + def revision(self): + return self._revision + + def committer(self): + return self._committer # None if committer isn't in committers.py + + def committer_email(self): + return self._committer_email + + def bug_id(self): + return self._changelog_data["bug_id"] # May be None + + def author(self): + return self._changelog_data["author"] # May be None + + def author_name(self): + return self._changelog_data["author_name"] + + def author_email(self): + return self._changelog_data["author_email"] + + def reviewer(self): + return self._changelog_data["reviewer"] # May be None + + def reviewer_text(self): + return self._changelog_data["reviewer_text"] # May be None + + def changed_files(self): + return self._changelog_data["changed_files"] + + def to_json(self): + return { + "bug_id": self.bug_id(), + "author_name": self.author_name(), + "author_email": self.author_email(), + "reviewer_text": self.reviewer_text(), + "changed_files": self.changed_files(), + } + + def responsible_parties(self): + responsible_parties = [ + self.committer(), + self.author(), + self.reviewer(), + ] + return set([party for party in responsible_parties if party]) # Filter out None + + # FIXME: It is slightly lame that this "view" method is on this "model" class (in MVC terms) + def blame_string(self, bugs): + string = "r%s:\n" % self.revision() + string += " %s\n" % urls.view_revision_url(self.revision()) + string += " Bug: %s (%s)\n" % (self.bug_id(), bugs.bug_url_for_bug_id(self.bug_id())) + author_line = "\"%s\" <%s>" % (self.author_name(), self.author_email()) + string += " Author: %s\n" % (self.author() or author_line) + string += " Reviewer: %s\n" % (self.reviewer() or self.reviewer_text()) + string += " Committer: %s" % self.committer() + return string diff --git a/Tools/Scripts/webkitpy/common/checkout/commitinfo_unittest.py b/Tools/Scripts/webkitpy/common/checkout/commitinfo_unittest.py new file mode 100644 index 000000000..f58e6f1ea --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/commitinfo_unittest.py @@ -0,0 +1,61 @@ +# Copyright (C) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import unittest + +from webkitpy.common.checkout.commitinfo import CommitInfo +from webkitpy.common.config.committers import CommitterList, Committer, Reviewer + +class CommitInfoTest(unittest.TestCase): + + def test_commit_info_creation(self): + author = Committer("Author", "author@example.com") + committer = Committer("Committer", "committer@example.com") + reviewer = Reviewer("Reviewer", "reviewer@example.com") + committer_list = CommitterList(committers=[author, committer], reviewers=[reviewer]) + + changelog_data = { + "bug_id": 1234, + "author_name": "Committer", + "author_email": "author@example.com", + "author": author, + "reviewer_text": "Reviewer", + "reviewer": reviewer, + } + commit = CommitInfo(123, "committer@example.com", changelog_data, committer_list) + + self.assertEqual(commit.revision(), 123) + self.assertEqual(commit.bug_id(), 1234) + self.assertEqual(commit.author_name(), "Committer") + self.assertEqual(commit.author_email(), "author@example.com") + self.assertEqual(commit.author(), author) + self.assertEqual(commit.reviewer_text(), "Reviewer") + self.assertEqual(commit.reviewer(), reviewer) + self.assertEqual(commit.committer(), committer) + self.assertEqual(commit.committer_email(), "committer@example.com") + self.assertEqual(commit.responsible_parties(), set([author, committer, reviewer])) diff --git a/Tools/Scripts/webkitpy/common/checkout/deps.py b/Tools/Scripts/webkitpy/common/checkout/deps.py new file mode 100644 index 000000000..2f3a8731e --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/deps.py @@ -0,0 +1,61 @@ +# Copyright (C) 2011, Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# WebKit's Python module for parsing and modifying ChangeLog files + +import codecs +import fileinput +import re +import textwrap + + +class DEPS(object): + + _variable_regexp = r"\s+'%s':\s+'(?P<value>\d+)'" + + def __init__(self, path): + # FIXME: This should take a FileSystem object. + self._path = path + + def read_variable(self, name): + pattern = re.compile(self._variable_regexp % name) + for line in fileinput.FileInput(self._path): + match = pattern.match(line) + if match: + return int(match.group("value")) + + def write_variable(self, name, value): + pattern = re.compile(self._variable_regexp % name) + replacement_line = " '%s': '%s'" % (name, value) + # inplace=1 creates a backup file and re-directs stdout to the file + for line in fileinput.FileInput(self._path, inplace=1): + if pattern.match(line): + print replacement_line + continue + # Trailing comma suppresses printing newline + print line, diff --git a/Tools/Scripts/webkitpy/common/checkout/deps_mock.py b/Tools/Scripts/webkitpy/common/checkout/deps_mock.py new file mode 100644 index 000000000..cb57e8b28 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/deps_mock.py @@ -0,0 +1,38 @@ +# Copyright (C) 2011 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +from webkitpy.common.system.deprecated_logging import log + + +class MockDEPS(object): + def read_variable(self, name): + return 6564 + + def write_variable(self, name, value): + log("MOCK: MockDEPS.write_variable(%s, %s)" % (name, value)) diff --git a/Tools/Scripts/webkitpy/common/checkout/diff_parser.py b/Tools/Scripts/webkitpy/common/checkout/diff_parser.py new file mode 100644 index 000000000..2ed552c45 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/diff_parser.py @@ -0,0 +1,186 @@ +# Copyright (C) 2009 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""WebKit's Python module for interacting with patches.""" + +import logging +import re + +_log = logging.getLogger(__name__) + + +# FIXME: This is broken. We should compile our regexps up-front +# instead of using a custom cache. +_regexp_compile_cache = {} + + +# FIXME: This function should be removed. +def match(pattern, string): + """Matches the string with the pattern, caching the compiled regexp.""" + if not pattern in _regexp_compile_cache: + _regexp_compile_cache[pattern] = re.compile(pattern) + return _regexp_compile_cache[pattern].match(string) + + +# FIXME: This belongs on DiffParser (e.g. as to_svn_diff()). +def git_diff_to_svn_diff(line): + """Converts a git formatted diff line to a svn formatted line. + + Args: + line: A string representing a line of the diff. + """ + # FIXME: This list should be a class member on DiffParser. + # These regexp patterns should be compiled once instead of every time. + conversion_patterns = (("^diff --git \w/(.+) \w/(?P<FilePath>.+)", lambda matched: "Index: " + matched.group('FilePath') + "\n"), + ("^new file.*", lambda matched: "\n"), + ("^index [0-9a-f]{7}\.\.[0-9a-f]{7} [0-9]{6}", lambda matched: "===================================================================\n"), + ("^--- \w/(?P<FilePath>.+)", lambda matched: "--- " + matched.group('FilePath') + "\n"), + ("^\+\+\+ \w/(?P<FilePath>.+)", lambda matched: "+++ " + matched.group('FilePath') + "\n")) + + for pattern, conversion in conversion_patterns: + matched = match(pattern, line) + if matched: + return conversion(matched) + return line + + +# FIXME: This method belongs on DiffParser +def get_diff_converter(first_diff_line): + """Gets a converter function of diff lines. + + Args: + first_diff_line: The first filename line of a diff file. + If this line is git formatted, we'll return a + converter from git to SVN. + """ + if match(r"^diff --git \w/", first_diff_line): + return git_diff_to_svn_diff + return lambda input: input + + +_INITIAL_STATE = 1 +_DECLARED_FILE_PATH = 2 +_PROCESSING_CHUNK = 3 + + +class DiffFile(object): + """Contains the information for one file in a patch. + + The field "lines" is a list which contains tuples in this format: + (deleted_line_number, new_line_number, line_string) + If deleted_line_number is zero, it means this line is newly added. + If new_line_number is zero, it means this line is deleted. + """ + # FIXME: Tuples generally grow into classes. We should consider + # adding a DiffLine object. + + def added_or_modified_line_numbers(self): + # This logic was moved from patchreader.py, but may not be + # the right API for this object long-term. + return [line[1] for line in self.lines if not line[0]] + + def __init__(self, filename): + self.filename = filename + self.lines = [] + + def add_new_line(self, line_number, line): + self.lines.append((0, line_number, line)) + + def add_deleted_line(self, line_number, line): + self.lines.append((line_number, 0, line)) + + def add_unchanged_line(self, deleted_line_number, new_line_number, line): + self.lines.append((deleted_line_number, new_line_number, line)) + + +# If this is going to be called DiffParser, it should be a re-useable parser. +# Otherwise we should rename it to ParsedDiff or just Diff. +class DiffParser(object): + """A parser for a patch file. + + The field "files" is a dict whose key is the filename and value is + a DiffFile object. + """ + + def __init__(self, diff_input): + """Parses a diff. + + Args: + diff_input: An iterable object. + """ + self.files = self._parse_into_diff_files(diff_input) + + # FIXME: This function is way too long and needs to be broken up. + def _parse_into_diff_files(self, diff_input): + files = {} + state = _INITIAL_STATE + current_file = None + old_diff_line = None + new_diff_line = None + for line in diff_input: + line = line.rstrip("\n") + if state == _INITIAL_STATE: + transform_line = get_diff_converter(line) + line = transform_line(line) + + file_declaration = match(r"^Index: (?P<FilePath>.+)", line) + if file_declaration: + filename = file_declaration.group('FilePath') + current_file = DiffFile(filename) + files[filename] = current_file + state = _DECLARED_FILE_PATH + continue + + lines_changed = match(r"^@@ -(?P<OldStartLine>\d+)(,\d+)? \+(?P<NewStartLine>\d+)(,\d+)? @@", line) + if lines_changed: + if state != _DECLARED_FILE_PATH and state != _PROCESSING_CHUNK: + _log.error('Unexpected line change without file path ' + 'declaration: %r' % line) + old_diff_line = int(lines_changed.group('OldStartLine')) + new_diff_line = int(lines_changed.group('NewStartLine')) + state = _PROCESSING_CHUNK + continue + + if state == _PROCESSING_CHUNK: + if line.startswith('+'): + current_file.add_new_line(new_diff_line, line[1:]) + new_diff_line += 1 + elif line.startswith('-'): + current_file.add_deleted_line(old_diff_line, line[1:]) + old_diff_line += 1 + elif line.startswith(' '): + current_file.add_unchanged_line(old_diff_line, new_diff_line, line[1:]) + old_diff_line += 1 + new_diff_line += 1 + elif line == '\\ No newline at end of file': + # Nothing to do. We may still have some added lines. + pass + else: + _log.error('Unexpected diff format when parsing a ' + 'chunk: %r' % line) + return files diff --git a/Tools/Scripts/webkitpy/common/checkout/diff_parser_unittest.py b/Tools/Scripts/webkitpy/common/checkout/diff_parser_unittest.py new file mode 100644 index 000000000..d61a0989b --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/diff_parser_unittest.py @@ -0,0 +1,94 @@ +# Copyright (C) 2009 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import unittest +import diff_parser +import re + +from webkitpy.common.checkout.diff_test_data import DIFF_TEST_DATA + +class DiffParserTest(unittest.TestCase): + def test_diff_parser(self, parser = None): + if not parser: + parser = diff_parser.DiffParser(DIFF_TEST_DATA.splitlines()) + self.assertEquals(3, len(parser.files)) + + self.assertTrue('WebCore/rendering/style/StyleFlexibleBoxData.h' in parser.files) + diff = parser.files['WebCore/rendering/style/StyleFlexibleBoxData.h'] + self.assertEquals(7, len(diff.lines)) + # The first two unchaged lines. + self.assertEquals((47, 47), diff.lines[0][0:2]) + self.assertEquals('', diff.lines[0][2]) + self.assertEquals((48, 48), diff.lines[1][0:2]) + self.assertEquals(' unsigned align : 3; // EBoxAlignment', diff.lines[1][2]) + # The deleted line + self.assertEquals((50, 0), diff.lines[3][0:2]) + self.assertEquals(' unsigned orient: 1; // EBoxOrient', diff.lines[3][2]) + + # The first file looks OK. Let's check the next, more complicated file. + self.assertTrue('WebCore/rendering/style/StyleRareInheritedData.cpp' in parser.files) + diff = parser.files['WebCore/rendering/style/StyleRareInheritedData.cpp'] + # There are 3 chunks. + self.assertEquals(7 + 7 + 9, len(diff.lines)) + # Around an added line. + self.assertEquals((60, 61), diff.lines[9][0:2]) + self.assertEquals((0, 62), diff.lines[10][0:2]) + self.assertEquals((61, 63), diff.lines[11][0:2]) + # Look through the last chunk, which contains both add's and delete's. + self.assertEquals((81, 83), diff.lines[14][0:2]) + self.assertEquals((82, 84), diff.lines[15][0:2]) + self.assertEquals((83, 85), diff.lines[16][0:2]) + self.assertEquals((84, 0), diff.lines[17][0:2]) + self.assertEquals((0, 86), diff.lines[18][0:2]) + self.assertEquals((0, 87), diff.lines[19][0:2]) + self.assertEquals((85, 88), diff.lines[20][0:2]) + self.assertEquals((86, 89), diff.lines[21][0:2]) + self.assertEquals((87, 90), diff.lines[22][0:2]) + + # Check if a newly added file is correctly handled. + diff = parser.files['LayoutTests/platform/mac/fast/flexbox/box-orient-button-expected.checksum'] + self.assertEquals(1, len(diff.lines)) + self.assertEquals((0, 1), diff.lines[0][0:2]) + + def test_git_mnemonicprefix(self): + p = re.compile(r' ([a|b])/') + + prefixes = [ + { 'a' : 'i', 'b' : 'w' }, # git-diff (compares the (i)ndex and the (w)ork tree) + { 'a' : 'c', 'b' : 'w' }, # git-diff HEAD (compares a (c)ommit and the (w)ork tree) + { 'a' : 'c', 'b' : 'i' }, # git diff --cached (compares a (c)ommit and the (i)ndex) + { 'a' : 'o', 'b' : 'w' }, # git-diff HEAD:file1 file2 (compares an (o)bject and a (w)ork tree entity) + { 'a' : '1', 'b' : '2' }, # git diff --no-index a b (compares two non-git things (1) and (2)) + ] + + for prefix in prefixes: + patch = p.sub(lambda x: " %s/" % prefix[x.group(1)], DIFF_TEST_DATA) + self.test_diff_parser(diff_parser.DiffParser(patch.splitlines())) + +if __name__ == '__main__': + unittest.main() diff --git a/Tools/Scripts/webkitpy/common/checkout/diff_test_data.py b/Tools/Scripts/webkitpy/common/checkout/diff_test_data.py new file mode 100644 index 000000000..5f1719da8 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/diff_test_data.py @@ -0,0 +1,80 @@ +# Copyright (C) 2011 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +# FIXME: Store this as a .patch file in some new fixtures directory or similar. +DIFF_TEST_DATA = '''diff --git a/WebCore/rendering/style/StyleFlexibleBoxData.h b/WebCore/rendering/style/StyleFlexibleBoxData.h +index f5d5e74..3b6aa92 100644 +--- a/WebCore/rendering/style/StyleFlexibleBoxData.h ++++ b/WebCore/rendering/style/StyleFlexibleBoxData.h +@@ -47,7 +47,6 @@ public: + + unsigned align : 3; // EBoxAlignment + unsigned pack: 3; // EBoxAlignment +- unsigned orient: 1; // EBoxOrient + unsigned lines : 1; // EBoxLines + + private: +diff --git a/WebCore/rendering/style/StyleRareInheritedData.cpp b/WebCore/rendering/style/StyleRareInheritedData.cpp +index ce21720..324929e 100644 +--- a/WebCore/rendering/style/StyleRareInheritedData.cpp ++++ b/WebCore/rendering/style/StyleRareInheritedData.cpp +@@ -39,6 +39,7 @@ StyleRareInheritedData::StyleRareInheritedData() + , textSizeAdjust(RenderStyle::initialTextSizeAdjust()) + , resize(RenderStyle::initialResize()) + , userSelect(RenderStyle::initialUserSelect()) ++ , boxOrient(RenderStyle::initialBoxOrient()) + { + } + +@@ -58,6 +59,7 @@ StyleRareInheritedData::StyleRareInheritedData(const StyleRareInheritedData& o) + , textSizeAdjust(o.textSizeAdjust) + , resize(o.resize) + , userSelect(o.userSelect) ++ , boxOrient(o.boxOrient) + { + } + +@@ -81,7 +83,8 @@ bool StyleRareInheritedData::operator==(const StyleRareInheritedData& o) const + && khtmlLineBreak == o.khtmlLineBreak + && textSizeAdjust == o.textSizeAdjust + && resize == o.resize +- && userSelect == o.userSelect; ++ && userSelect == o.userSelect ++ && boxOrient == o.boxOrient; + } + + bool StyleRareInheritedData::shadowDataEquivalent(const StyleRareInheritedData& o) const +diff --git a/LayoutTests/platform/mac/fast/flexbox/box-orient-button-expected.checksum b/LayoutTests/platform/mac/fast/flexbox/box-orient-button-expected.checksum +new file mode 100644 +index 0000000..6db26bd +--- /dev/null ++++ b/LayoutTests/platform/mac/fast/flexbox/box-orient-button-expected.checksum +@@ -0,0 +1 @@ ++61a373ee739673a9dcd7bac62b9f182e +\ No newline at end of file +''' diff --git a/Tools/Scripts/webkitpy/common/checkout/scm/__init__.py b/Tools/Scripts/webkitpy/common/checkout/scm/__init__.py new file mode 100644 index 000000000..f691f58e1 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/scm/__init__.py @@ -0,0 +1,8 @@ +# Required for Python to search this directory for module files + +# We only export public API here. +from .commitmessage import CommitMessage +from .detection import SCMDetector +from .git import Git, AmbiguousCommitError +from .scm import SCM, AuthenticationError, CheckoutNeedsUpdate +from .svn import SVN diff --git a/Tools/Scripts/webkitpy/common/checkout/scm/commitmessage.py b/Tools/Scripts/webkitpy/common/checkout/scm/commitmessage.py new file mode 100644 index 000000000..be0d431f9 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/scm/commitmessage.py @@ -0,0 +1,62 @@ +# Copyright (c) 2009, 2010, 2011 Google Inc. All rights reserved. +# Copyright (c) 2009 Apple Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import re + + +def _first_non_empty_line_after_index(lines, index=0): + first_non_empty_line = index + for line in lines[index:]: + if re.match("^\s*$", line): + first_non_empty_line += 1 + else: + break + return first_non_empty_line + + +class CommitMessage: + def __init__(self, message): + self.message_lines = message[_first_non_empty_line_after_index(message, 0):] + + def body(self, lstrip=False): + lines = self.message_lines[_first_non_empty_line_after_index(self.message_lines, 1):] + if lstrip: + lines = [line.lstrip() for line in lines] + return "\n".join(lines) + "\n" + + def description(self, lstrip=False, strip_url=False): + line = self.message_lines[0] + if lstrip: + line = line.lstrip() + if strip_url: + line = re.sub("^(\s*)<.+> ", "\1", line) + return line + + def message(self): + return "\n".join(self.message_lines) + "\n" diff --git a/Tools/Scripts/webkitpy/common/checkout/scm/detection.py b/Tools/Scripts/webkitpy/common/checkout/scm/detection.py new file mode 100644 index 000000000..ac12d7521 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/scm/detection.py @@ -0,0 +1,81 @@ +# Copyright (c) 2009, 2010, 2011 Google Inc. All rights reserved. +# Copyright (c) 2009 Apple Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from webkitpy.common.system.filesystem import FileSystem +from webkitpy.common.system.executive import Executive + +from webkitpy.common.system.deprecated_logging import log + +from .svn import SVN +from .git import Git + + +class SCMDetector(object): + def __init__(self, filesystem, executive): + self._filesystem = filesystem + self._executive = executive + + def default_scm(self, patch_directories=None): + """Return the default SCM object as determined by the CWD and running code. + + Returns the default SCM object for the current working directory; if the + CWD is not in a checkout, then we attempt to figure out if the SCM module + itself is part of a checkout, and return that one. If neither is part of + a checkout, None is returned. + """ + cwd = self._filesystem.getcwd() + scm_system = self.detect_scm_system(cwd, patch_directories) + if not scm_system: + script_directory = self._filesystem.dirname(self._filesystem.path_to_module(self.__module__)) + scm_system = self.detect_scm_system(script_directory, patch_directories) + if scm_system: + log("The current directory (%s) is not a WebKit checkout, using %s" % (cwd, scm_system.checkout_root)) + else: + raise Exception("FATAL: Failed to determine the SCM system for either %s or %s" % (cwd, script_directory)) + return scm_system + + def detect_scm_system(self, path, patch_directories=None): + absolute_path = self._filesystem.abspath(path) + + if patch_directories == []: + patch_directories = None + + if SVN.in_working_directory(absolute_path): + return SVN(cwd=absolute_path, patch_directories=patch_directories, filesystem=self._filesystem, executive=self._executive) + + if Git.in_working_directory(absolute_path): + return Git(cwd=absolute_path, filesystem=self._filesystem, executive=self._executive) + + return None + + +# FIXME: These free functions are all deprecated: + +def detect_scm_system(path, patch_directories=None): + return SCMDetector(FileSystem(), Executive()).detect_scm_system(path, patch_directories) diff --git a/Tools/Scripts/webkitpy/common/checkout/scm/git.py b/Tools/Scripts/webkitpy/common/checkout/scm/git.py new file mode 100644 index 000000000..7c23be056 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/scm/git.py @@ -0,0 +1,481 @@ +# Copyright (c) 2009, 2010, 2011 Google Inc. All rights reserved. +# Copyright (c) 2009 Apple Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import logging +import os +import re + +from webkitpy.common.memoized import memoized +from webkitpy.common.system.deprecated_logging import log +from webkitpy.common.system.executive import Executive, ScriptError +from webkitpy.common.system import ospath + +from .commitmessage import CommitMessage +from .scm import AuthenticationError, SCM, commit_error_handler +from .svn import SVN, SVNRepository + + +_log = logging.getLogger(__name__) + + +def run_command(*args, **kwargs): + # FIXME: This should not be a global static. + # New code should use Executive.run_command directly instead + return Executive().run_command(*args, **kwargs) + + +class AmbiguousCommitError(Exception): + def __init__(self, num_local_commits, working_directory_is_clean): + self.num_local_commits = num_local_commits + self.working_directory_is_clean = working_directory_is_clean + + +class Git(SCM, SVNRepository): + + # Git doesn't appear to document error codes, but seems to return + # 1 or 128, mostly. + ERROR_FILE_IS_MISSING = 128 + + def __init__(self, cwd, **kwargs): + SCM.__init__(self, cwd, **kwargs) + self._check_git_architecture() + + def _machine_is_64bit(self): + import platform + # This only is tested on Mac. + if not platform.mac_ver()[0]: + return False + + # platform.architecture()[0] can be '64bit' even if the machine is 32bit: + # http://mail.python.org/pipermail/pythonmac-sig/2009-September/021648.html + # Use the sysctl command to find out what the processor actually supports. + return self.run(['sysctl', '-n', 'hw.cpu64bit_capable']).rstrip() == '1' + + def _executable_is_64bit(self, path): + # Again, platform.architecture() fails us. On my machine + # git_bits = platform.architecture(executable=git_path, bits='default')[0] + # git_bits is just 'default', meaning the call failed. + file_output = self.run(['file', path]) + return re.search('x86_64', file_output) + + def _check_git_architecture(self): + if not self._machine_is_64bit(): + return + + # We could path-search entirely in python or with + # which.py (http://code.google.com/p/which), but this is easier: + git_path = self.run(['which', 'git']).rstrip() + if self._executable_is_64bit(git_path): + return + + webkit_dev_thread_url = "https://lists.webkit.org/pipermail/webkit-dev/2010-December/015287.html" + log("Warning: This machine is 64-bit, but the git binary (%s) does not support 64-bit.\nInstall a 64-bit git for better performance, see:\n%s\n" % (git_path, webkit_dev_thread_url)) + + @classmethod + def in_working_directory(cls, path): + try: + # FIXME: This should use an Executive. + return run_command(['git', 'rev-parse', '--is-inside-work-tree'], cwd=path, error_handler=Executive.ignore_error).rstrip() == "true" + except OSError, e: + # The Windows bots seem to through a WindowsError when git isn't installed. + return False + + @classmethod + def find_checkout_root(cls, path): + # FIXME: This should use a FileSystem object instead of os.path. + # "git rev-parse --show-cdup" would be another way to get to the root + (checkout_root, dot_git) = os.path.split(run_command(['git', 'rev-parse', '--git-dir'], cwd=(path or "./"))) + if not os.path.isabs(checkout_root): # Sometimes git returns relative paths + checkout_root = os.path.join(path, checkout_root) + return checkout_root + + @classmethod + def to_object_name(cls, filepath): + # FIXME: This should use a FileSystem object instead of os.path. + root_end_with_slash = os.path.join(cls.find_checkout_root(os.path.dirname(filepath)), '') + return filepath.replace(root_end_with_slash, '') + + @classmethod + def read_git_config(cls, key, cwd=None): + # FIXME: This should probably use cwd=self.checkout_root. + # Pass --get-all for cases where the config has multiple values + # Pass the cwd if provided so that we can handle the case of running webkit-patch outside of the working directory. + # FIXME: This should use an Executive. + return run_command(["git", "config", "--get-all", key], error_handler=Executive.ignore_error, cwd=cwd).rstrip('\n') + + @staticmethod + def commit_success_regexp(): + return "^Committed r(?P<svn_revision>\d+)$" + + def discard_local_commits(self): + # FIXME: This should probably use cwd=self.checkout_root + self.run(['git', 'reset', '--hard', self.remote_branch_ref()]) + + def local_commits(self): + return self.run(['git', 'log', '--pretty=oneline', 'HEAD...' + self.remote_branch_ref()], cwd=self.checkout_root).splitlines() + + def rebase_in_progress(self): + return self._filesystem.exists(self.absolute_path(self._filesystem.join('.git', 'rebase-apply'))) + + def working_directory_is_clean(self): + return self.run(['git', 'diff', 'HEAD', '--no-renames', '--name-only'], cwd=self.checkout_root) == "" + + def clean_working_directory(self): + # FIXME: These should probably use cwd=self.checkout_root. + # Could run git clean here too, but that wouldn't match working_directory_is_clean + self.run(['git', 'reset', '--hard', 'HEAD']) + # Aborting rebase even though this does not match working_directory_is_clean + if self.rebase_in_progress(): + self.run(['git', 'rebase', '--abort']) + + def status_command(self): + # git status returns non-zero when there are changes, so we use git diff name --name-status HEAD instead. + # No file contents printed, thus utf-8 autodecoding in self.run is fine. + return ["git", "diff", "--name-status", "--no-renames", "HEAD"] + + def _status_regexp(self, expected_types): + return '^(?P<status>[%s])\t(?P<filename>.+)$' % expected_types + + def add(self, path, return_exit_code=False): + return self.run(["git", "add", path], return_exit_code=return_exit_code) + + def delete(self, path): + return self.run(["git", "rm", "-f", path]) + + def exists(self, path): + return_code = self.run(["git", "show", "HEAD:%s" % path], return_exit_code=True, decode_output=False) + return return_code != self.ERROR_FILE_IS_MISSING + + def merge_base(self, git_commit): + if git_commit: + # Special-case HEAD.. to mean working-copy changes only. + if git_commit.upper() == 'HEAD..': + return 'HEAD' + + if '..' not in git_commit: + git_commit = git_commit + "^.." + git_commit + return git_commit + + return self.remote_merge_base() + + def changed_files(self, git_commit=None): + # FIXME: --diff-filter could be used to avoid the "extract_filenames" step. + status_command = ['git', 'diff', '-r', '--name-status', "--no-renames", "--no-ext-diff", "--full-index", self.merge_base(git_commit)] + # FIXME: I'm not sure we're returning the same set of files that SVN.changed_files is. + # Added (A), Copied (C), Deleted (D), Modified (M), Renamed (R) + return self.run_status_and_extract_filenames(status_command, self._status_regexp("ADM")) + + def _changes_files_for_commit(self, git_commit): + # --pretty="format:" makes git show not print the commit log header, + changed_files = self.run(["git", "show", "--pretty=format:", "--name-only", git_commit]).splitlines() + # instead it just prints a blank line at the top, so we skip the blank line: + return changed_files[1:] + + def changed_files_for_revision(self, revision): + commit_id = self.git_commit_from_svn_revision(revision) + return self._changes_files_for_commit(commit_id) + + def revisions_changing_file(self, path, limit=5): + # raise a script error if path does not exists to match the behavior of the svn implementation. + if not self._filesystem.exists(path): + raise ScriptError(message="Path %s does not exist." % path) + + # git rev-list head --remove-empty --limit=5 -- path would be equivalent. + commit_ids = self.run(["git", "log", "--remove-empty", "--pretty=format:%H", "-%s" % limit, "--", path]).splitlines() + return filter(lambda revision: revision, map(self.svn_revision_from_git_commit, commit_ids)) + + def conflicted_files(self): + # We do not need to pass decode_output for this diff command + # as we're passing --name-status which does not output any data. + status_command = ['git', 'diff', '--name-status', '--no-renames', '--diff-filter=U'] + return self.run_status_and_extract_filenames(status_command, self._status_regexp("U")) + + def added_files(self): + return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("A")) + + def deleted_files(self): + return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("D")) + + @staticmethod + def supports_local_commits(): + return True + + def display_name(self): + return "git" + + def head_svn_revision(self): + _log.debug('Running git.head_svn_revision... (Temporary logging message)') + git_log = self.run(['git', 'log', '-25']) + match = re.search("^\s*git-svn-id:.*@(?P<svn_revision>\d+)\ ", git_log, re.MULTILINE) + if not match: + return "" + return str(match.group('svn_revision')) + + def prepend_svn_revision(self, diff): + revision = self.head_svn_revision() + if not revision: + return diff + + return "Subversion Revision: " + revision + '\n' + diff + + def create_patch(self, git_commit=None, changed_files=None): + """Returns a byte array (str()) representing the patch file. + Patch files are effectively binary since they may contain + files of multiple different encodings.""" + + # Put code changes at the top of the patch and layout tests + # at the bottom, this makes for easier reviewing. + config_path = self._filesystem.dirname(self._filesystem.path_to_module('webkitpy.common.config')) + order_file = self._filesystem.join(config_path, 'orderfile') + order = "" + if self._filesystem.exists(order_file): + order = "-O%s" % order_file + + command = ['git', 'diff', '--binary', "--no-ext-diff", "--full-index", "--no-renames", order, self.merge_base(git_commit), "--"] + if changed_files: + command += changed_files + return self.prepend_svn_revision(self.run(command, decode_output=False, cwd=self.checkout_root)) + + def _run_git_svn_find_rev(self, arg): + # git svn find-rev always exits 0, even when the revision or commit is not found. + return self.run(['git', 'svn', 'find-rev', arg], cwd=self.checkout_root).rstrip() + + def _string_to_int_or_none(self, string): + try: + return int(string) + except ValueError, e: + return None + + @memoized + def git_commit_from_svn_revision(self, svn_revision): + git_commit = self._run_git_svn_find_rev('r%s' % svn_revision) + if not git_commit: + # FIXME: Alternatively we could offer to update the checkout? Or return None? + raise ScriptError(message='Failed to find git commit for revision %s, your checkout likely needs an update.' % svn_revision) + return git_commit + + @memoized + def svn_revision_from_git_commit(self, git_commit): + svn_revision = self._run_git_svn_find_rev(git_commit) + return self._string_to_int_or_none(svn_revision) + + def contents_at_revision(self, path, revision): + """Returns a byte array (str()) containing the contents + of path @ revision in the repository.""" + return self.run(["git", "show", "%s:%s" % (self.git_commit_from_svn_revision(revision), path)], decode_output=False) + + def diff_for_revision(self, revision): + git_commit = self.git_commit_from_svn_revision(revision) + return self.create_patch(git_commit) + + def diff_for_file(self, path, log=None): + return self.run(['git', 'diff', 'HEAD', '--no-renames', '--', path], cwd=self.checkout_root) + + def show_head(self, path): + return self.run(['git', 'show', 'HEAD:' + self.to_object_name(path)], decode_output=False) + + def committer_email_for_revision(self, revision): + git_commit = self.git_commit_from_svn_revision(revision) + committer_email = self.run(["git", "log", "-1", "--pretty=format:%ce", git_commit]) + # Git adds an extra @repository_hash to the end of every committer email, remove it: + return committer_email.rsplit("@", 1)[0] + + def apply_reverse_diff(self, revision): + # Assume the revision is an svn revision. + git_commit = self.git_commit_from_svn_revision(revision) + # I think this will always fail due to ChangeLogs. + self.run(['git', 'revert', '--no-commit', git_commit], error_handler=Executive.ignore_error) + + def revert_files(self, file_paths): + self.run(['git', 'checkout', 'HEAD'] + file_paths) + + def _assert_can_squash(self, working_directory_is_clean): + squash = Git.read_git_config('webkit-patch.commit-should-always-squash', cwd=self.checkout_root) + should_squash = squash and squash.lower() == "true" + + if not should_squash: + # Only warn if there are actually multiple commits to squash. + num_local_commits = len(self.local_commits()) + if num_local_commits > 1 or (num_local_commits > 0 and not working_directory_is_clean): + raise AmbiguousCommitError(num_local_commits, working_directory_is_clean) + + def commit_with_message(self, message, username=None, password=None, git_commit=None, force_squash=False, changed_files=None): + # Username is ignored during Git commits. + working_directory_is_clean = self.working_directory_is_clean() + + if git_commit: + # Special-case HEAD.. to mean working-copy changes only. + if git_commit.upper() == 'HEAD..': + if working_directory_is_clean: + raise ScriptError(message="The working copy is not modified. --git-commit=HEAD.. only commits working copy changes.") + self.commit_locally_with_message(message) + return self._commit_on_branch(message, 'HEAD', username=username, password=password) + + # Need working directory changes to be committed so we can checkout the merge branch. + if not working_directory_is_clean: + # FIXME: webkit-patch land will modify the ChangeLogs to correct the reviewer. + # That will modify the working-copy and cause us to hit this error. + # The ChangeLog modification could be made to modify the existing local commit. + raise ScriptError(message="Working copy is modified. Cannot commit individual git_commits.") + return self._commit_on_branch(message, git_commit, username=username, password=password) + + if not force_squash: + self._assert_can_squash(working_directory_is_clean) + self.run(['git', 'reset', '--soft', self.remote_merge_base()], cwd=self.checkout_root) + self.commit_locally_with_message(message) + return self.push_local_commits_to_server(username=username, password=password) + + def _commit_on_branch(self, message, git_commit, username=None, password=None): + branch_ref = self.run(['git', 'symbolic-ref', 'HEAD']).strip() + branch_name = branch_ref.replace('refs/heads/', '') + commit_ids = self.commit_ids_from_commitish_arguments([git_commit]) + + # We want to squash all this branch's commits into one commit with the proper description. + # We do this by doing a "merge --squash" into a new commit branch, then dcommitting that. + MERGE_BRANCH_NAME = 'webkit-patch-land' + self.delete_branch(MERGE_BRANCH_NAME) + + # We might be in a directory that's present in this branch but not in the + # trunk. Move up to the top of the tree so that git commands that expect a + # valid CWD won't fail after we check out the merge branch. + # FIXME: We should never be using chdir! We can instead pass cwd= to run_command/self.run! + self._filesystem.chdir(self.checkout_root) + + # Stuff our change into the merge branch. + # We wrap in a try...finally block so if anything goes wrong, we clean up the branches. + commit_succeeded = True + try: + self.run(['git', 'checkout', '-q', '-b', MERGE_BRANCH_NAME, self.remote_branch_ref()]) + + for commit in commit_ids: + # We're on a different branch now, so convert "head" to the branch name. + commit = re.sub(r'(?i)head', branch_name, commit) + # FIXME: Once changed_files and create_patch are modified to separately handle each + # commit in a commit range, commit each cherry pick so they'll get dcommitted separately. + self.run(['git', 'cherry-pick', '--no-commit', commit]) + + self.run(['git', 'commit', '-m', message]) + output = self.push_local_commits_to_server(username=username, password=password) + except Exception, e: + log("COMMIT FAILED: " + str(e)) + output = "Commit failed." + commit_succeeded = False + finally: + # And then swap back to the original branch and clean up. + self.clean_working_directory() + self.run(['git', 'checkout', '-q', branch_name]) + self.delete_branch(MERGE_BRANCH_NAME) + + return output + + def svn_commit_log(self, svn_revision): + svn_revision = self.strip_r_from_svn_revision(svn_revision) + return self.run(['git', 'svn', 'log', '-r', svn_revision]) + + def last_svn_commit_log(self): + return self.run(['git', 'svn', 'log', '--limit=1']) + + def svn_blame(self, path): + return self.run(['git', 'svn', 'blame', path]) + + # Git-specific methods: + def _branch_ref_exists(self, branch_ref): + return self.run(['git', 'show-ref', '--quiet', '--verify', branch_ref], return_exit_code=True) == 0 + + def delete_branch(self, branch_name): + if self._branch_ref_exists('refs/heads/' + branch_name): + self.run(['git', 'branch', '-D', branch_name]) + + def remote_merge_base(self): + return self.run(['git', 'merge-base', self.remote_branch_ref(), 'HEAD'], cwd=self.checkout_root).strip() + + def remote_branch_ref(self): + # Use references so that we can avoid collisions, e.g. we don't want to operate on refs/heads/trunk if it exists. + remote_branch_refs = Git.read_git_config('svn-remote.svn.fetch', cwd=self.checkout_root) + if not remote_branch_refs: + remote_master_ref = 'refs/remotes/origin/master' + if not self._branch_ref_exists(remote_master_ref): + raise ScriptError(message="Can't find a branch to diff against. svn-remote.svn.fetch is not in the git config and %s does not exist" % remote_master_ref) + return remote_master_ref + + # FIXME: What's the right behavior when there are multiple svn-remotes listed? + # For now, just use the first one. + first_remote_branch_ref = remote_branch_refs.split('\n')[0] + return first_remote_branch_ref.split(':')[1] + + def commit_locally_with_message(self, message): + self.run(['git', 'commit', '--all', '-F', '-'], input=message, cwd=self.checkout_root) + + def push_local_commits_to_server(self, username=None, password=None): + dcommit_command = ['git', 'svn', 'dcommit'] + if self.dryrun: + dcommit_command.append('--dry-run') + if (not username or not password) and not self.has_authorization_for_realm(SVN.svn_server_realm): + raise AuthenticationError(SVN.svn_server_host, prompt_for_password=True) + if username: + dcommit_command.extend(["--username", username]) + output = self.run(dcommit_command, error_handler=commit_error_handler, input=password, cwd=self.checkout_root) + # Return a string which looks like a commit so that things which parse this output will succeed. + if self.dryrun: + output += "\nCommitted r0" + return output + + # This function supports the following argument formats: + # no args : rev-list trunk..HEAD + # A..B : rev-list A..B + # A...B : error! + # A B : [A, B] (different from git diff, which would use "rev-list A..B") + def commit_ids_from_commitish_arguments(self, args): + if not len(args): + args.append('%s..HEAD' % self.remote_branch_ref()) + + commit_ids = [] + for commitish in args: + if '...' in commitish: + raise ScriptError(message="'...' is not supported (found in '%s'). Did you mean '..'?" % commitish) + elif '..' in commitish: + commit_ids += reversed(self.run(['git', 'rev-list', commitish]).splitlines()) + else: + # Turn single commits or branch or tag names into commit ids. + commit_ids += self.run(['git', 'rev-parse', '--revs-only', commitish]).splitlines() + return commit_ids + + def commit_message_for_local_commit(self, commit_id): + commit_lines = self.run(['git', 'cat-file', 'commit', commit_id]).splitlines() + + # Skip the git headers. + first_line_after_headers = 0 + for line in commit_lines: + first_line_after_headers += 1 + if line == "": + break + return CommitMessage(commit_lines[first_line_after_headers:]) + + def files_changed_summary_for_commit(self, commit_id): + return self.run(['git', 'diff-tree', '--shortstat', '--no-renames', '--no-commit-id', commit_id]) diff --git a/Tools/Scripts/webkitpy/common/checkout/scm/scm.py b/Tools/Scripts/webkitpy/common/checkout/scm/scm.py new file mode 100644 index 000000000..b00470bbb --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/scm/scm.py @@ -0,0 +1,240 @@ +# Copyright (c) 2009, Google Inc. All rights reserved. +# Copyright (c) 2009 Apple Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Python module for interacting with an SCM system (like SVN or Git) + +import logging +import re + +from webkitpy.common.system.deprecated_logging import error, log +from webkitpy.common.system.executive import Executive, ScriptError +from webkitpy.common.system.filesystem import FileSystem + + +class CheckoutNeedsUpdate(ScriptError): + def __init__(self, script_args, exit_code, output, cwd): + ScriptError.__init__(self, script_args=script_args, exit_code=exit_code, output=output, cwd=cwd) + + +# FIXME: Should be moved onto SCM +def commit_error_handler(error): + if re.search("resource out of date", error.output): + raise CheckoutNeedsUpdate(script_args=error.script_args, exit_code=error.exit_code, output=error.output, cwd=error.cwd) + Executive.default_error_handler(error) + + +class AuthenticationError(Exception): + def __init__(self, server_host, prompt_for_password=False): + self.server_host = server_host + self.prompt_for_password = prompt_for_password + + + +# SCM methods are expected to return paths relative to self.checkout_root. +class SCM: + def __init__(self, cwd, executive=None, filesystem=None): + self.cwd = cwd + self.checkout_root = self.find_checkout_root(self.cwd) + self.dryrun = False + self._executive = executive or Executive() + self._filesystem = filesystem or FileSystem() + + # A wrapper used by subclasses to create processes. + def run(self, args, cwd=None, input=None, error_handler=None, return_exit_code=False, return_stderr=True, decode_output=True): + # FIXME: We should set cwd appropriately. + return self._executive.run_command(args, + cwd=cwd, + input=input, + error_handler=error_handler, + return_exit_code=return_exit_code, + return_stderr=return_stderr, + decode_output=decode_output) + + # SCM always returns repository relative path, but sometimes we need + # absolute paths to pass to rm, etc. + def absolute_path(self, repository_relative_path): + return self._filesystem.join(self.checkout_root, repository_relative_path) + + # FIXME: This belongs in Checkout, not SCM. + def scripts_directory(self): + return self._filesystem.join(self.checkout_root, "Tools", "Scripts") + + # FIXME: This belongs in Checkout, not SCM. + def script_path(self, script_name): + return self._filesystem.join(self.scripts_directory(), script_name) + + def ensure_clean_working_directory(self, force_clean): + if self.working_directory_is_clean(): + return + if not force_clean: + print self.run(self.status_command(), error_handler=Executive.ignore_error, cwd=self.checkout_root) + raise ScriptError(message="Working directory has modifications, pass --force-clean or --no-clean to continue.") + log("Cleaning working directory") + self.clean_working_directory() + + def ensure_no_local_commits(self, force): + if not self.supports_local_commits(): + return + commits = self.local_commits() + if not len(commits): + return + if not force: + error("Working directory has local commits, pass --force-clean to continue.") + self.discard_local_commits() + + def run_status_and_extract_filenames(self, status_command, status_regexp): + filenames = [] + # We run with cwd=self.checkout_root so that returned-paths are root-relative. + for line in self.run(status_command, cwd=self.checkout_root).splitlines(): + match = re.search(status_regexp, line) + if not match: + continue + # status = match.group('status') + filename = match.group('filename') + filenames.append(filename) + return filenames + + def strip_r_from_svn_revision(self, svn_revision): + match = re.match("^r(?P<svn_revision>\d+)", unicode(svn_revision)) + if (match): + return match.group('svn_revision') + return svn_revision + + def svn_revision_from_commit_text(self, commit_text): + match = re.search(self.commit_success_regexp(), commit_text, re.MULTILINE) + return match.group('svn_revision') + + @staticmethod + def _subclass_must_implement(): + raise NotImplementedError("subclasses must implement") + + @staticmethod + def in_working_directory(path): + SCM._subclass_must_implement() + + @staticmethod + def find_checkout_root(path): + SCM._subclass_must_implement() + + @staticmethod + def commit_success_regexp(): + SCM._subclass_must_implement() + + def working_directory_is_clean(self): + self._subclass_must_implement() + + def clean_working_directory(self): + self._subclass_must_implement() + + def status_command(self): + self._subclass_must_implement() + + def add(self, path, return_exit_code=False): + self._subclass_must_implement() + + def delete(self, path): + self._subclass_must_implement() + + def exists(self, path): + self._subclass_must_implement() + + def changed_files(self, git_commit=None): + self._subclass_must_implement() + + def changed_files_for_revision(self, revision): + self._subclass_must_implement() + + def revisions_changing_file(self, path, limit=5): + self._subclass_must_implement() + + def added_files(self): + self._subclass_must_implement() + + def conflicted_files(self): + self._subclass_must_implement() + + def display_name(self): + self._subclass_must_implement() + + def head_svn_revision(self): + self._subclass_must_implement() + + def create_patch(self, git_commit=None, changed_files=None): + self._subclass_must_implement() + + def committer_email_for_revision(self, revision): + self._subclass_must_implement() + + def contents_at_revision(self, path, revision): + self._subclass_must_implement() + + def diff_for_revision(self, revision): + self._subclass_must_implement() + + def diff_for_file(self, path, log=None): + self._subclass_must_implement() + + def show_head(self, path): + self._subclass_must_implement() + + def apply_reverse_diff(self, revision): + self._subclass_must_implement() + + def revert_files(self, file_paths): + self._subclass_must_implement() + + def commit_with_message(self, message, username=None, password=None, git_commit=None, force_squash=False, changed_files=None): + self._subclass_must_implement() + + def svn_commit_log(self, svn_revision): + self._subclass_must_implement() + + def last_svn_commit_log(self): + self._subclass_must_implement() + + def svn_blame(self, path): + self._subclass_must_implement() + + # Subclasses must indicate if they support local commits, + # but the SCM baseclass will only call local_commits methods when this is true. + @staticmethod + def supports_local_commits(): + SCM._subclass_must_implement() + + def remote_merge_base(): + SCM._subclass_must_implement() + + def commit_locally_with_message(self, message): + error("Your source control manager does not support local commits.") + + def discard_local_commits(self): + pass + + def local_commits(self): + return [] diff --git a/Tools/Scripts/webkitpy/common/checkout/scm/scm_mock.py b/Tools/Scripts/webkitpy/common/checkout/scm/scm_mock.py new file mode 100644 index 000000000..78af67c98 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/scm/scm_mock.py @@ -0,0 +1,114 @@ +# Copyright (C) 2011 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from webkitpy.common.system.filesystem_mock import MockFileSystem +from webkitpy.common.system.executive_mock import MockExecutive + + +class MockSCM(object): + def __init__(self, filesystem=None, executive=None): + self.checkout_root = "/mock-checkout" + self.added_paths = set() + self._filesystem = filesystem or MockFileSystem() + self._executive = executive or MockExecutive() + + def add(self, destination_path, return_exit_code=False): + self.added_paths.add(destination_path) + if return_exit_code: + return 0 + + def ensure_clean_working_directory(self, force_clean): + pass + + def supports_local_commits(self): + return True + + def ensure_no_local_commits(self, force_clean): + pass + + def exists(self, path): + # TestRealMain.test_real_main (and several other rebaseline tests) are sensitive to this return value. + # We should make those tests more robust, but for now we just return True always (since no test needs otherwise). + return True + + def absolute_path(self, *comps): + return self._filesystem.join(self.checkout_root, *comps) + + def changed_files(self, git_commit=None): + return ["MockFile1"] + + def changed_files_for_revision(self, revision): + return ["MockFile1"] + + def head_svn_revision(self): + return 1234 + + def create_patch(self, git_commit, changed_files=None): + return "Patch1" + + def commit_ids_from_commitish_arguments(self, args): + return ["Commitish1", "Commitish2"] + + def committer_email_for_revision(self, revision): + return "mock@webkit.org" + + def commit_locally_with_message(self, message): + pass + + def commit_with_message(self, message, username=None, password=None, git_commit=None, force_squash=False, changed_files=None): + pass + + def merge_base(self, git_commit): + return None + + def commit_message_for_local_commit(self, commit_id): + if commit_id == "Commitish1": + return CommitMessage("CommitMessage1\n" \ + "https://bugs.example.org/show_bug.cgi?id=50000\n") + if commit_id == "Commitish2": + return CommitMessage("CommitMessage2\n" \ + "https://bugs.example.org/show_bug.cgi?id=50001\n") + raise Exception("Bogus commit_id in commit_message_for_local_commit.") + + def diff_for_file(self, path, log=None): + return path + '-diff' + + def diff_for_revision(self, revision): + return "DiffForRevision%s\nhttp://bugs.webkit.org/show_bug.cgi?id=12345" % revision + + def show_head(self, path): + return path + + def svn_revision_from_commit_text(self, commit_text): + return "49824" + + def delete(self, path): + if not self._filesystem: + return + if self._filesystem.exists(path): + self._filesystem.remove(path) diff --git a/Tools/Scripts/webkitpy/common/checkout/scm/scm_unittest.py b/Tools/Scripts/webkitpy/common/checkout/scm/scm_unittest.py new file mode 100644 index 000000000..ae5ac845c --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/scm/scm_unittest.py @@ -0,0 +1,1640 @@ +# Copyright (C) 2009 Google Inc. All rights reserved. +# Copyright (C) 2009 Apple Inc. All rights reserved. +# Copyright (C) 2011 Daniel Bates (dbates@intudata.com). All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from __future__ import with_statement + +import atexit +import base64 +import codecs +import getpass +import os +import os.path +import re +import stat +import sys +import subprocess +import tempfile +import time +import unittest +import urllib +import shutil + +from datetime import date +from webkitpy.common.checkout.checkout import Checkout +from webkitpy.common.config.committers import Committer # FIXME: This should not be needed +from webkitpy.common.net.bugzilla import Attachment # FIXME: This should not be needed +from webkitpy.common.system.executive import Executive, ScriptError +from webkitpy.common.system.outputcapture import OutputCapture +from webkitpy.common.system.executive_mock import MockExecutive + +from .detection import find_checkout_root, default_scm, detect_scm_system +from .git import Git, AmbiguousCommitError +from .scm import SCM, CheckoutNeedsUpdate, commit_error_handler, AuthenticationError +from .svn import SVN + +# We cache the mock SVN repo so that we don't create it again for each call to an SVNTest or GitTest test_ method. +# We store it in a global variable so that we can delete this cached repo on exit(3). +# FIXME: Remove this once we migrate to Python 2.7. Unittest in Python 2.7 supports module-specific setup and teardown functions. +cached_svn_repo_path = None + + +def remove_dir(path): + # Change directory to / to ensure that we aren't in the directory we want to delete. + os.chdir('/') + shutil.rmtree(path) + + +# FIXME: Remove this once we migrate to Python 2.7. Unittest in Python 2.7 supports module-specific setup and teardown functions. +@atexit.register +def delete_cached_mock_repo_at_exit(): + if cached_svn_repo_path: + remove_dir(cached_svn_repo_path) + +# Eventually we will want to write tests which work for both scms. (like update_webkit, changed_files, etc.) +# Perhaps through some SCMTest base-class which both SVNTest and GitTest inherit from. + + +def run_command(*args, **kwargs): + # FIXME: This should not be a global static. + # New code should use Executive.run_command directly instead + return Executive().run_command(*args, **kwargs) + + +# FIXME: This should be unified into one of the executive.py commands! +# Callers could use run_and_throw_if_fail(args, cwd=cwd, quiet=True) +def run_silent(args, cwd=None): + # Note: Not thread safe: http://bugs.python.org/issue2320 + process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd) + process.communicate() # ignore output + exit_code = process.wait() + if exit_code: + raise ScriptError('Failed to run "%s" exit_code: %d cwd: %s' % (args, exit_code, cwd)) + + +def write_into_file_at_path(file_path, contents, encoding="utf-8"): + if encoding: + with codecs.open(file_path, "w", encoding) as file: + file.write(contents) + else: + with open(file_path, "w") as file: + file.write(contents) + + +def read_from_path(file_path, encoding="utf-8"): + with codecs.open(file_path, "r", encoding) as file: + return file.read() + + +def _make_diff(command, *args): + # We use this wrapper to disable output decoding. diffs should be treated as + # binary files since they may include text files of multiple differnet encodings. + # FIXME: This should use an Executive. + return run_command([command, "diff"] + list(args), decode_output=False) + + +def _svn_diff(*args): + return _make_diff("svn", *args) + + +def _git_diff(*args): + return _make_diff("git", *args) + + +# Exists to share svn repository creation code between the git and svn tests +class SVNTestRepository: + @classmethod + def _svn_add(cls, path): + run_command(["svn", "add", path]) + + @classmethod + def _svn_commit(cls, message): + run_command(["svn", "commit", "--quiet", "--message", message]) + + @classmethod + def _setup_test_commits(cls, svn_repo_url): + + svn_checkout_path = tempfile.mkdtemp(suffix="svn_test_checkout") + run_command(['svn', 'checkout', '--quiet', svn_repo_url, svn_checkout_path]) + + # Add some test commits + os.chdir(svn_checkout_path) + + write_into_file_at_path("test_file", "test1") + cls._svn_add("test_file") + cls._svn_commit("initial commit") + + write_into_file_at_path("test_file", "test1test2") + # This used to be the last commit, but doing so broke + # GitTest.test_apply_git_patch which use the inverse diff of the last commit. + # svn-apply fails to remove directories in Git, see: + # https://bugs.webkit.org/show_bug.cgi?id=34871 + os.mkdir("test_dir") + # Slash should always be the right path separator since we use cygwin on Windows. + test_file3_path = "test_dir/test_file3" + write_into_file_at_path(test_file3_path, "third file") + cls._svn_add("test_dir") + cls._svn_commit("second commit") + + write_into_file_at_path("test_file", "test1test2test3\n") + write_into_file_at_path("test_file2", "second file") + cls._svn_add("test_file2") + cls._svn_commit("third commit") + + # This 4th commit is used to make sure that our patch file handling + # code correctly treats patches as binary and does not attempt to + # decode them assuming they're utf-8. + write_into_file_at_path("test_file", u"latin1 test: \u00A0\n", "latin1") + write_into_file_at_path("test_file2", u"utf-8 test: \u00A0\n", "utf-8") + cls._svn_commit("fourth commit") + + # svn does not seem to update after commit as I would expect. + run_command(['svn', 'update']) + remove_dir(svn_checkout_path) + + # This is a hot function since it's invoked by unittest before calling each test_ method in SVNTest and + # GitTest. We create a mock SVN repo once and then perform an SVN checkout from a filesystem copy of + # it since it's expensive to create the mock repo. + @classmethod + def setup(cls, test_object): + global cached_svn_repo_path + if not cached_svn_repo_path: + cached_svn_repo_path = cls._setup_mock_repo() + + test_object.temp_directory = tempfile.mkdtemp(suffix="svn_test") + test_object.svn_repo_path = os.path.join(test_object.temp_directory, "repo") + test_object.svn_repo_url = "file://%s" % test_object.svn_repo_path + test_object.svn_checkout_path = os.path.join(test_object.temp_directory, "checkout") + shutil.copytree(cached_svn_repo_path, test_object.svn_repo_path) + run_command(['svn', 'checkout', '--quiet', test_object.svn_repo_url + "/trunk", test_object.svn_checkout_path]) + + @classmethod + def _setup_mock_repo(cls): + # Create an test SVN repository + svn_repo_path = tempfile.mkdtemp(suffix="svn_test_repo") + svn_repo_url = "file://%s" % svn_repo_path # Not sure this will work on windows + # git svn complains if we don't pass --pre-1.5-compatible, not sure why: + # Expected FS format '2'; found format '3' at /usr/local/libexec/git-core//git-svn line 1477 + run_command(['svnadmin', 'create', '--pre-1.5-compatible', svn_repo_path]) + + # Create a test svn checkout + svn_checkout_path = tempfile.mkdtemp(suffix="svn_test_checkout") + run_command(['svn', 'checkout', '--quiet', svn_repo_url, svn_checkout_path]) + + # Create and checkout a trunk dir to match the standard svn configuration to match git-svn's expectations + os.chdir(svn_checkout_path) + os.mkdir('trunk') + cls._svn_add('trunk') + # We can add tags and branches as well if we ever need to test those. + cls._svn_commit('add trunk') + + # Change directory out of the svn checkout so we can delete the checkout directory. + remove_dir(svn_checkout_path) + + cls._setup_test_commits(svn_repo_url + "/trunk") + return svn_repo_path + + @classmethod + def tear_down(cls, test_object): + remove_dir(test_object.temp_directory) + + # Now that we've deleted the checkout paths, cwddir may be invalid + # Change back to a valid directory so that later calls to os.getcwd() do not fail. + if os.path.isabs(__file__): + path = os.path.dirname(__file__) + else: + path = sys.path[0] + os.chdir(detect_scm_system(path).checkout_root) + + +# FIXME: This should move to testing SCMDetector instead. +class StandaloneFunctionsTest(unittest.TestCase): + """This class tests any standalone/top-level functions in the package.""" + def setUp(self): + self.orig_cwd = os.path.abspath(os.getcwd()) + self.orig_abspath = os.path.abspath + + # We capture but ignore the output from stderr to reduce unwanted + # logging. + self.output = OutputCapture() + self.output.capture_output() + + def tearDown(self): + os.chdir(self.orig_cwd) + os.path.abspath = self.orig_abspath + self.output.restore_output() + + def test_find_checkout_root(self): + # Test from inside the tree. + os.chdir(sys.path[0]) + dir = find_checkout_root() + self.assertNotEqual(dir, None) + self.assertTrue(os.path.exists(dir)) + + # Test from outside the tree. + os.chdir(os.path.expanduser("~")) + dir = find_checkout_root() + self.assertNotEqual(dir, None) + self.assertTrue(os.path.exists(dir)) + + # Mock out abspath() to test being not in a checkout at all. + os.path.abspath = lambda x: "/" + self.assertRaises(SystemExit, find_checkout_root) + os.path.abspath = self.orig_abspath + + def test_default_scm(self): + # Test from inside the tree. + os.chdir(sys.path[0]) + scm = default_scm() + self.assertNotEqual(scm, None) + + # Test from outside the tree. + os.chdir(os.path.expanduser("~")) + dir = find_checkout_root() + self.assertNotEqual(dir, None) + + # Mock out abspath() to test being not in a checkout at all. + os.path.abspath = lambda x: "/" + self.assertRaises(Exception, default_scm) + os.path.abspath = self.orig_abspath + + +# For testing the SCM baseclass directly. +class SCMClassTests(unittest.TestCase): + def setUp(self): + self.dev_null = open(os.devnull, "w") # Used to make our Popen calls quiet. + + def tearDown(self): + self.dev_null.close() + + def test_run_command_with_pipe(self): + input_process = subprocess.Popen(['echo', 'foo\nbar'], stdout=subprocess.PIPE, stderr=self.dev_null) + self.assertEqual(run_command(['grep', 'bar'], input=input_process.stdout), "bar\n") + + # Test the non-pipe case too: + self.assertEqual(run_command(['grep', 'bar'], input="foo\nbar"), "bar\n") + + command_returns_non_zero = ['/bin/sh', '--invalid-option'] + # Test when the input pipe process fails. + input_process = subprocess.Popen(command_returns_non_zero, stdout=subprocess.PIPE, stderr=self.dev_null) + self.assertTrue(input_process.poll() != 0) + self.assertRaises(ScriptError, run_command, ['grep', 'bar'], input=input_process.stdout) + + # Test when the run_command process fails. + input_process = subprocess.Popen(['echo', 'foo\nbar'], stdout=subprocess.PIPE, stderr=self.dev_null) # grep shows usage and calls exit(2) when called w/o arguments. + self.assertRaises(ScriptError, run_command, command_returns_non_zero, input=input_process.stdout) + + def test_error_handlers(self): + git_failure_message="Merge conflict during commit: Your file or directory 'WebCore/ChangeLog' is probably out-of-date: resource out of date; try updating at /usr/local/libexec/git-core//git-svn line 469" + svn_failure_message="""svn: Commit failed (details follow): +svn: File or directory 'ChangeLog' is out of date; try updating +svn: resource out of date; try updating +""" + command_does_not_exist = ['does_not_exist', 'invalid_option'] + self.assertRaises(OSError, run_command, command_does_not_exist) + self.assertRaises(OSError, run_command, command_does_not_exist, error_handler=Executive.ignore_error) + + command_returns_non_zero = ['/bin/sh', '--invalid-option'] + self.assertRaises(ScriptError, run_command, command_returns_non_zero) + # Check if returns error text: + self.assertTrue(run_command(command_returns_non_zero, error_handler=Executive.ignore_error)) + + self.assertRaises(CheckoutNeedsUpdate, commit_error_handler, ScriptError(output=git_failure_message)) + self.assertRaises(CheckoutNeedsUpdate, commit_error_handler, ScriptError(output=svn_failure_message)) + self.assertRaises(ScriptError, commit_error_handler, ScriptError(output='blah blah blah')) + + +# GitTest and SVNTest inherit from this so any test_ methods here will be run once for this class and then once for each subclass. +class SCMTest(unittest.TestCase): + def _create_patch(self, patch_contents): + # FIXME: This code is brittle if the Attachment API changes. + attachment = Attachment({"bug_id": 12345}, None) + attachment.contents = lambda: patch_contents + + joe_cool = Committer("Joe Cool", "joe@cool.com") + attachment.reviewer = lambda: joe_cool + + return attachment + + def _setup_webkittools_scripts_symlink(self, local_scm): + webkit_scm = detect_scm_system(os.path.dirname(os.path.abspath(__file__))) + webkit_scripts_directory = webkit_scm.scripts_directory() + local_scripts_directory = local_scm.scripts_directory() + os.mkdir(os.path.dirname(local_scripts_directory)) + os.symlink(webkit_scripts_directory, local_scripts_directory) + + # Tests which both GitTest and SVNTest should run. + # FIXME: There must be a simpler way to add these w/o adding a wrapper method to both subclasses + + def _shared_test_changed_files(self): + write_into_file_at_path("test_file", "changed content") + self.assertEqual(self.scm.changed_files(), ["test_file"]) + write_into_file_at_path("test_dir/test_file3", "new stuff") + self.assertEqual(self.scm.changed_files(), ["test_dir/test_file3", "test_file"]) + old_cwd = os.getcwd() + os.chdir("test_dir") + # Validate that changed_files does not change with our cwd, see bug 37015. + self.assertEqual(self.scm.changed_files(), ["test_dir/test_file3", "test_file"]) + os.chdir(old_cwd) + + def _shared_test_added_files(self): + write_into_file_at_path("test_file", "changed content") + self.assertEqual(self.scm.added_files(), []) + + write_into_file_at_path("added_file", "new stuff") + self.scm.add("added_file") + + os.mkdir("added_dir") + write_into_file_at_path("added_dir/added_file2", "new stuff") + self.scm.add("added_dir") + + # SVN reports directory changes, Git does not. + added_files = self.scm.added_files() + if "added_dir" in added_files: + added_files.remove("added_dir") + self.assertEqual(added_files, ["added_dir/added_file2", "added_file"]) + + # Test also to make sure clean_working_directory removes added files + self.scm.clean_working_directory() + self.assertEqual(self.scm.added_files(), []) + self.assertFalse(os.path.exists("added_file")) + self.assertFalse(os.path.exists("added_dir")) + + def _shared_test_changed_files_for_revision(self): + # SVN reports directory changes, Git does not. + changed_files = self.scm.changed_files_for_revision(3) + if "test_dir" in changed_files: + changed_files.remove("test_dir") + self.assertEqual(changed_files, ["test_dir/test_file3", "test_file"]) + self.assertEqual(sorted(self.scm.changed_files_for_revision(4)), sorted(["test_file", "test_file2"])) # Git and SVN return different orders. + self.assertEqual(self.scm.changed_files_for_revision(2), ["test_file"]) + + def _shared_test_contents_at_revision(self): + self.assertEqual(self.scm.contents_at_revision("test_file", 3), "test1test2") + self.assertEqual(self.scm.contents_at_revision("test_file", 4), "test1test2test3\n") + + # Verify that contents_at_revision returns a byte array, aka str(): + self.assertEqual(self.scm.contents_at_revision("test_file", 5), u"latin1 test: \u00A0\n".encode("latin1")) + self.assertEqual(self.scm.contents_at_revision("test_file2", 5), u"utf-8 test: \u00A0\n".encode("utf-8")) + + self.assertEqual(self.scm.contents_at_revision("test_file2", 4), "second file") + # Files which don't exist: + # Currently we raise instead of returning None because detecting the difference between + # "file not found" and any other error seems impossible with svn (git seems to expose such through the return code). + self.assertRaises(ScriptError, self.scm.contents_at_revision, "test_file2", 2) + self.assertRaises(ScriptError, self.scm.contents_at_revision, "does_not_exist", 2) + + def _shared_test_revisions_changing_file(self): + self.assertEqual(self.scm.revisions_changing_file("test_file"), [5, 4, 3, 2]) + self.assertRaises(ScriptError, self.scm.revisions_changing_file, "non_existent_file") + + def _shared_test_committer_email_for_revision(self): + self.assertEqual(self.scm.committer_email_for_revision(3), getpass.getuser()) # Committer "email" will be the current user + + def _shared_test_reverse_diff(self): + self._setup_webkittools_scripts_symlink(self.scm) # Git's apply_reverse_diff uses resolve-ChangeLogs + # Only test the simple case, as any other will end up with conflict markers. + self.scm.apply_reverse_diff('5') + self.assertEqual(read_from_path('test_file'), "test1test2test3\n") + + def _shared_test_diff_for_revision(self): + # Patch formats are slightly different between svn and git, so just regexp for things we know should be there. + r3_patch = self.scm.diff_for_revision(4) + self.assertTrue(re.search('test3', r3_patch)) + self.assertFalse(re.search('test4', r3_patch)) + self.assertTrue(re.search('test2', r3_patch)) + self.assertTrue(re.search('test2', self.scm.diff_for_revision(3))) + + def _shared_test_svn_apply_git_patch(self): + self._setup_webkittools_scripts_symlink(self.scm) + git_binary_addition = """diff --git a/fizzbuzz7.gif b/fizzbuzz7.gif +new file mode 100644 +index 0000000000000000000000000000000000000000..64a9532e7794fcd791f6f12157406d90 +60151690 +GIT binary patch +literal 512 +zcmZ?wbhEHbRAx|MU|?iW{Kxc~?KofD;ckY;H+&5HnHl!!GQMD7h+sU{_)e9f^V3c? +zhJP##HdZC#4K}7F68@!1jfWQg2daCm-gs#3|JREDT>c+pG4L<_2;w##WMO#ysPPap +zLqpAf1OE938xAsSp4!5f-o><?VKe(#0jEcwfHGF4%M1^kRs14oVBp2ZEL{E1N<-zJ +zsfLmOtKta;2_;2c#^S1-8cf<nb!QnGl>c!Xe6RXvrEtAWBvSDTgTO1j3vA31Puw!A +zs(87q)j_mVDTqBo-P+03-P5mHCEnJ+x}YdCuS7#bCCyePUe(ynK+|4b-3qK)T?Z&) +zYG+`tl4h?GZv_$t82}X4*DTE|$;{DEiPyF@)U-1+FaX++T9H{&%cag`W1|zVP@`%b +zqiSkp6{BTpWTkCr!=<C6Q=?#~R8^JfrliAF6Q^gV9Iup8RqCXqqhqC`qsyhk<-nlB +z00f{QZvfK&|Nm#oZ0TQl`Yr$BIa6A@16O26ud7H<QM=xl`toLKnz-3h@9c9q&wm|X +z{89I|WPyD!*M?gv?q`;L=2YFeXrJQNti4?}s!zFo=5CzeBxC69xA<zrjP<wUcCRh4 +ptUl-ZG<%a~#LwkIWv&q!KSCH7tQ8cJDiw+|GV?MN)RjY50RTb-xvT&H + +literal 0 +HcmV?d00001 + +""" + self.checkout.apply_patch(self._create_patch(git_binary_addition)) + added = read_from_path('fizzbuzz7.gif', encoding=None) + self.assertEqual(512, len(added)) + self.assertTrue(added.startswith('GIF89a')) + self.assertTrue('fizzbuzz7.gif' in self.scm.changed_files()) + + # The file already exists. + self.assertRaises(ScriptError, self.checkout.apply_patch, self._create_patch(git_binary_addition)) + + git_binary_modification = """diff --git a/fizzbuzz7.gif b/fizzbuzz7.gif +index 64a9532e7794fcd791f6f12157406d9060151690..323fae03f4606ea9991df8befbb2fca7 +GIT binary patch +literal 7 +OcmYex&reD$;sO8*F9L)B + +literal 512 +zcmZ?wbhEHbRAx|MU|?iW{Kxc~?KofD;ckY;H+&5HnHl!!GQMD7h+sU{_)e9f^V3c? +zhJP##HdZC#4K}7F68@!1jfWQg2daCm-gs#3|JREDT>c+pG4L<_2;w##WMO#ysPPap +zLqpAf1OE938xAsSp4!5f-o><?VKe(#0jEcwfHGF4%M1^kRs14oVBp2ZEL{E1N<-zJ +zsfLmOtKta;2_;2c#^S1-8cf<nb!QnGl>c!Xe6RXvrEtAWBvSDTgTO1j3vA31Puw!A +zs(87q)j_mVDTqBo-P+03-P5mHCEnJ+x}YdCuS7#bCCyePUe(ynK+|4b-3qK)T?Z&) +zYG+`tl4h?GZv_$t82}X4*DTE|$;{DEiPyF@)U-1+FaX++T9H{&%cag`W1|zVP@`%b +zqiSkp6{BTpWTkCr!=<C6Q=?#~R8^JfrliAF6Q^gV9Iup8RqCXqqhqC`qsyhk<-nlB +z00f{QZvfK&|Nm#oZ0TQl`Yr$BIa6A@16O26ud7H<QM=xl`toLKnz-3h@9c9q&wm|X +z{89I|WPyD!*M?gv?q`;L=2YFeXrJQNti4?}s!zFo=5CzeBxC69xA<zrjP<wUcCRh4 +ptUl-ZG<%a~#LwkIWv&q!KSCH7tQ8cJDiw+|GV?MN)RjY50RTb-xvT&H + +""" + self.checkout.apply_patch(self._create_patch(git_binary_modification)) + modified = read_from_path('fizzbuzz7.gif', encoding=None) + self.assertEqual('foobar\n', modified) + self.assertTrue('fizzbuzz7.gif' in self.scm.changed_files()) + + # Applying the same modification should fail. + self.assertRaises(ScriptError, self.checkout.apply_patch, self._create_patch(git_binary_modification)) + + git_binary_deletion = """diff --git a/fizzbuzz7.gif b/fizzbuzz7.gif +deleted file mode 100644 +index 323fae0..0000000 +GIT binary patch +literal 0 +HcmV?d00001 + +literal 7 +OcmYex&reD$;sO8*F9L)B + +""" + self.checkout.apply_patch(self._create_patch(git_binary_deletion)) + self.assertFalse(os.path.exists('fizzbuzz7.gif')) + self.assertFalse('fizzbuzz7.gif' in self.scm.changed_files()) + + # Cannot delete again. + self.assertRaises(ScriptError, self.checkout.apply_patch, self._create_patch(git_binary_deletion)) + + def _shared_test_add_recursively(self): + os.mkdir("added_dir") + write_into_file_at_path("added_dir/added_file", "new stuff") + self.scm.add("added_dir/added_file") + self.assertTrue("added_dir/added_file" in self.scm.added_files()) + + def _shared_test_delete_recursively(self): + os.mkdir("added_dir") + write_into_file_at_path("added_dir/added_file", "new stuff") + self.scm.add("added_dir/added_file") + self.assertTrue("added_dir/added_file" in self.scm.added_files()) + self.scm.delete("added_dir/added_file") + self.assertFalse("added_dir" in self.scm.added_files()) + + def _shared_test_delete_recursively_or_not(self): + os.mkdir("added_dir") + write_into_file_at_path("added_dir/added_file", "new stuff") + write_into_file_at_path("added_dir/another_added_file", "more new stuff") + self.scm.add("added_dir/added_file") + self.scm.add("added_dir/another_added_file") + self.assertTrue("added_dir/added_file" in self.scm.added_files()) + self.assertTrue("added_dir/another_added_file" in self.scm.added_files()) + self.scm.delete("added_dir/added_file") + self.assertTrue("added_dir/another_added_file" in self.scm.added_files()) + + def _shared_test_exists(self, scm, commit_function): + os.chdir(scm.checkout_root) + self.assertFalse(scm.exists('foo.txt')) + write_into_file_at_path('foo.txt', 'some stuff') + self.assertFalse(scm.exists('foo.txt')) + scm.add('foo.txt') + commit_function('adding foo') + self.assertTrue(scm.exists('foo.txt')) + scm.delete('foo.txt') + commit_function('deleting foo') + self.assertFalse(scm.exists('foo.txt')) + + def _shared_test_head_svn_revision(self): + self.assertEqual(self.scm.head_svn_revision(), '5') + + +# Context manager that overrides the current timezone. +class TimezoneOverride(object): + def __init__(self, timezone_string): + self._timezone_string = timezone_string + + def __enter__(self): + if hasattr(time, 'tzset'): + self._saved_timezone = os.environ.get('TZ', None) + os.environ['TZ'] = self._timezone_string + time.tzset() + + def __exit__(self, type, value, traceback): + if hasattr(time, 'tzset'): + if self._saved_timezone: + os.environ['TZ'] = self._saved_timezone + else: + del os.environ['TZ'] + time.tzset() + + +class SVNTest(SCMTest): + + @staticmethod + def _set_date_and_reviewer(changelog_entry): + # Joe Cool matches the reviewer set in SCMTest._create_patch + changelog_entry = changelog_entry.replace('REVIEWER_HERE', 'Joe Cool') + # svn-apply will update ChangeLog entries with today's date (as in Cupertino, CA, US) + with TimezoneOverride('PST8PDT'): + return changelog_entry.replace('DATE_HERE', date.today().isoformat()) + + def test_svn_apply(self): + first_entry = """2009-10-26 Eric Seidel <eric@webkit.org> + + Reviewed by Foo Bar. + + Most awesome change ever. + + * scm_unittest.py: +""" + intermediate_entry = """2009-10-27 Eric Seidel <eric@webkit.org> + + Reviewed by Baz Bar. + + A more awesomer change yet! + + * scm_unittest.py: +""" + one_line_overlap_patch = """Index: ChangeLog +=================================================================== +--- ChangeLog (revision 5) ++++ ChangeLog (working copy) +@@ -1,5 +1,13 @@ + 2009-10-26 Eric Seidel <eric@webkit.org> +%(whitespace)s ++ Reviewed by NOBODY (OOPS!). ++ ++ Second most awesome change ever. ++ ++ * scm_unittest.py: ++ ++2009-10-26 Eric Seidel <eric@webkit.org> ++ + Reviewed by Foo Bar. +%(whitespace)s + Most awesome change ever. +""" % {'whitespace': ' '} + one_line_overlap_entry = """DATE_HERE Eric Seidel <eric@webkit.org> + + Reviewed by REVIEWER_HERE. + + Second most awesome change ever. + + * scm_unittest.py: +""" + two_line_overlap_patch = """Index: ChangeLog +=================================================================== +--- ChangeLog (revision 5) ++++ ChangeLog (working copy) +@@ -2,6 +2,14 @@ +%(whitespace)s + Reviewed by Foo Bar. +%(whitespace)s ++ Second most awesome change ever. ++ ++ * scm_unittest.py: ++ ++2009-10-26 Eric Seidel <eric@webkit.org> ++ ++ Reviewed by Foo Bar. ++ + Most awesome change ever. +%(whitespace)s + * scm_unittest.py: +""" % {'whitespace': ' '} + two_line_overlap_entry = """DATE_HERE Eric Seidel <eric@webkit.org> + + Reviewed by Foo Bar. + + Second most awesome change ever. + + * scm_unittest.py: +""" + write_into_file_at_path('ChangeLog', first_entry) + run_command(['svn', 'add', 'ChangeLog']) + run_command(['svn', 'commit', '--quiet', '--message', 'ChangeLog commit']) + + # Patch files were created against just 'first_entry'. + # Add a second commit to make svn-apply have to apply the patches with fuzz. + changelog_contents = "%s\n%s" % (intermediate_entry, first_entry) + write_into_file_at_path('ChangeLog', changelog_contents) + run_command(['svn', 'commit', '--quiet', '--message', 'Intermediate commit']) + + self._setup_webkittools_scripts_symlink(self.scm) + self.checkout.apply_patch(self._create_patch(one_line_overlap_patch)) + expected_changelog_contents = "%s\n%s" % (self._set_date_and_reviewer(one_line_overlap_entry), changelog_contents) + self.assertEquals(read_from_path('ChangeLog'), expected_changelog_contents) + + self.scm.revert_files(['ChangeLog']) + self.checkout.apply_patch(self._create_patch(two_line_overlap_patch)) + expected_changelog_contents = "%s\n%s" % (self._set_date_and_reviewer(two_line_overlap_entry), changelog_contents) + self.assertEquals(read_from_path('ChangeLog'), expected_changelog_contents) + + def setUp(self): + SVNTestRepository.setup(self) + os.chdir(self.svn_checkout_path) + self.scm = detect_scm_system(self.svn_checkout_path) + # For historical reasons, we test some checkout code here too. + self.checkout = Checkout(self.scm) + + def tearDown(self): + SVNTestRepository.tear_down(self) + + def test_detect_scm_system_relative_url(self): + scm = detect_scm_system(".") + # I wanted to assert that we got the right path, but there was some + # crazy magic with temp folder names that I couldn't figure out. + self.assertTrue(scm.checkout_root) + + def test_create_patch_is_full_patch(self): + test_dir_path = os.path.join(self.svn_checkout_path, "test_dir2") + os.mkdir(test_dir_path) + test_file_path = os.path.join(test_dir_path, 'test_file2') + write_into_file_at_path(test_file_path, 'test content') + run_command(['svn', 'add', 'test_dir2']) + + # create_patch depends on 'svn-create-patch', so make a dummy version. + scripts_path = os.path.join(self.svn_checkout_path, 'Tools', 'Scripts') + os.makedirs(scripts_path) + create_patch_path = os.path.join(scripts_path, 'svn-create-patch') + write_into_file_at_path(create_patch_path, '#!/bin/sh\necho $PWD') # We could pass -n to prevent the \n, but not all echo accept -n. + os.chmod(create_patch_path, stat.S_IXUSR | stat.S_IRUSR) + + # Change into our test directory and run the create_patch command. + os.chdir(test_dir_path) + scm = detect_scm_system(test_dir_path) + self.assertEqual(scm.checkout_root, self.svn_checkout_path) # Sanity check that detection worked right. + patch_contents = scm.create_patch() + # Our fake 'svn-create-patch' returns $PWD instead of a patch, check that it was executed from the root of the repo. + self.assertEqual("%s\n" % os.path.realpath(scm.checkout_root), patch_contents) # Add a \n because echo adds a \n. + + def test_detection(self): + scm = detect_scm_system(self.svn_checkout_path) + self.assertEqual(scm.display_name(), "svn") + self.assertEqual(scm.supports_local_commits(), False) + + def test_apply_small_binary_patch(self): + patch_contents = """Index: test_file.swf +=================================================================== +Cannot display: file marked as a binary type. +svn:mime-type = application/octet-stream + +Property changes on: test_file.swf +___________________________________________________________________ +Name: svn:mime-type + + application/octet-stream + + +Q1dTBx0AAAB42itg4GlgYJjGwMDDyODMxMDw34GBgQEAJPQDJA== +""" + expected_contents = base64.b64decode("Q1dTBx0AAAB42itg4GlgYJjGwMDDyODMxMDw34GBgQEAJPQDJA==") + self._setup_webkittools_scripts_symlink(self.scm) + patch_file = self._create_patch(patch_contents) + self.checkout.apply_patch(patch_file) + actual_contents = read_from_path("test_file.swf", encoding=None) + self.assertEqual(actual_contents, expected_contents) + + def test_apply_svn_patch(self): + scm = detect_scm_system(self.svn_checkout_path) + patch = self._create_patch(_svn_diff("-r5:4")) + self._setup_webkittools_scripts_symlink(scm) + Checkout(scm).apply_patch(patch) + + def test_apply_svn_patch_force(self): + scm = detect_scm_system(self.svn_checkout_path) + patch = self._create_patch(_svn_diff("-r3:5")) + self._setup_webkittools_scripts_symlink(scm) + self.assertRaises(ScriptError, Checkout(scm).apply_patch, patch, force=True) + + def test_commit_logs(self): + # Commits have dates and usernames in them, so we can't just direct compare. + self.assertTrue(re.search('fourth commit', self.scm.last_svn_commit_log())) + self.assertTrue(re.search('second commit', self.scm.svn_commit_log(3))) + + def _shared_test_commit_with_message(self, username=None): + write_into_file_at_path('test_file', 'more test content') + commit_text = self.scm.commit_with_message("another test commit", username) + self.assertEqual(self.scm.svn_revision_from_commit_text(commit_text), '6') + + self.scm.dryrun = True + write_into_file_at_path('test_file', 'still more test content') + commit_text = self.scm.commit_with_message("yet another test commit", username) + self.assertEqual(self.scm.svn_revision_from_commit_text(commit_text), '0') + + def test_commit_in_subdir(self, username=None): + write_into_file_at_path('test_dir/test_file3', 'more test content') + os.chdir("test_dir") + commit_text = self.scm.commit_with_message("another test commit", username) + os.chdir("..") + self.assertEqual(self.scm.svn_revision_from_commit_text(commit_text), '6') + + def test_commit_text_parsing(self): + self._shared_test_commit_with_message() + + def test_commit_with_username(self): + self._shared_test_commit_with_message("dbates@webkit.org") + + def test_commit_without_authorization(self): + self.scm.has_authorization_for_realm = lambda realm: False + self.assertRaises(AuthenticationError, self._shared_test_commit_with_message) + + def test_has_authorization_for_realm_using_credentials_with_passtype(self): + credentials = """ +K 8 +passtype +V 8 +keychain +K 15 +svn:realmstring +V 39 +<http://svn.webkit.org:80> Mac OS Forge +K 8 +username +V 17 +dbates@webkit.org +END +""" + self.assertTrue(self._test_has_authorization_for_realm_using_credentials(SVN.svn_server_realm, credentials)) + + def test_has_authorization_for_realm_using_credentials_with_password(self): + credentials = """ +K 15 +svn:realmstring +V 39 +<http://svn.webkit.org:80> Mac OS Forge +K 8 +username +V 17 +dbates@webkit.org +K 8 +password +V 4 +blah +END +""" + self.assertTrue(self._test_has_authorization_for_realm_using_credentials(SVN.svn_server_realm, credentials)) + + def _test_has_authorization_for_realm_using_credentials(self, realm, credentials): + scm = detect_scm_system(self.svn_checkout_path) + fake_home_dir = tempfile.mkdtemp(suffix="fake_home_dir") + svn_config_dir_path = os.path.join(fake_home_dir, ".subversion") + os.mkdir(svn_config_dir_path) + fake_webkit_auth_file = os.path.join(svn_config_dir_path, "fake_webkit_auth_file") + write_into_file_at_path(fake_webkit_auth_file, credentials) + result = scm.has_authorization_for_realm(realm, home_directory=fake_home_dir) + os.remove(fake_webkit_auth_file) + os.rmdir(svn_config_dir_path) + os.rmdir(fake_home_dir) + return result + + def test_not_have_authorization_for_realm_with_credentials_missing_password_and_passtype(self): + credentials = """ +K 15 +svn:realmstring +V 39 +<http://svn.webkit.org:80> Mac OS Forge +K 8 +username +V 17 +dbates@webkit.org +END +""" + self.assertFalse(self._test_has_authorization_for_realm_using_credentials(SVN.svn_server_realm, credentials)) + + def test_not_have_authorization_for_realm_when_missing_credentials_file(self): + scm = detect_scm_system(self.svn_checkout_path) + fake_home_dir = tempfile.mkdtemp(suffix="fake_home_dir") + svn_config_dir_path = os.path.join(fake_home_dir, ".subversion") + os.mkdir(svn_config_dir_path) + self.assertFalse(scm.has_authorization_for_realm(SVN.svn_server_realm, home_directory=fake_home_dir)) + os.rmdir(svn_config_dir_path) + os.rmdir(fake_home_dir) + + def test_reverse_diff(self): + self._shared_test_reverse_diff() + + def test_diff_for_revision(self): + self._shared_test_diff_for_revision() + + def test_svn_apply_git_patch(self): + self._shared_test_svn_apply_git_patch() + + def test_changed_files(self): + self._shared_test_changed_files() + + def test_changed_files_for_revision(self): + self._shared_test_changed_files_for_revision() + + def test_added_files(self): + self._shared_test_added_files() + + def test_contents_at_revision(self): + self._shared_test_contents_at_revision() + + def test_revisions_changing_file(self): + self._shared_test_revisions_changing_file() + + def test_committer_email_for_revision(self): + self._shared_test_committer_email_for_revision() + + def test_add_recursively(self): + self._shared_test_add_recursively() + + def test_delete(self): + os.chdir(self.svn_checkout_path) + self.scm.delete("test_file") + self.assertTrue("test_file" in self.scm.deleted_files()) + + def test_delete_recursively(self): + self._shared_test_delete_recursively() + + def test_delete_recursively_or_not(self): + self._shared_test_delete_recursively_or_not() + + def test_head_svn_revision(self): + self._shared_test_head_svn_revision() + + def test_propset_propget(self): + filepath = os.path.join(self.svn_checkout_path, "test_file") + expected_mime_type = "x-application/foo-bar" + self.scm.propset("svn:mime-type", expected_mime_type, filepath) + self.assertEqual(expected_mime_type, self.scm.propget("svn:mime-type", filepath)) + + def test_show_head(self): + write_into_file_at_path("test_file", u"Hello!", "utf-8") + SVNTestRepository._svn_commit("fourth commit") + self.assertEqual("Hello!", self.scm.show_head('test_file')) + + def test_show_head_binary(self): + data = "\244" + write_into_file_at_path("binary_file", data, encoding=None) + self.scm.add("binary_file") + self.scm.commit_with_message("a test commit") + self.assertEqual(data, self.scm.show_head('binary_file')) + + def do_test_diff_for_file(self): + write_into_file_at_path('test_file', 'some content') + self.scm.commit_with_message("a test commit") + diff = self.scm.diff_for_file('test_file') + self.assertEqual(diff, "") + + write_into_file_at_path("test_file", "changed content") + diff = self.scm.diff_for_file('test_file') + self.assertTrue("-some content" in diff) + self.assertTrue("+changed content" in diff) + + def clean_bogus_dir(self): + self.bogus_dir = self.scm._bogus_dir_name() + if os.path.exists(self.bogus_dir): + shutil.rmtree(self.bogus_dir) + + def test_diff_for_file_with_existing_bogus_dir(self): + self.clean_bogus_dir() + os.mkdir(self.bogus_dir) + self.do_test_diff_for_file() + self.assertTrue(os.path.exists(self.bogus_dir)) + shutil.rmtree(self.bogus_dir) + + def test_diff_for_file_with_missing_bogus_dir(self): + self.clean_bogus_dir() + self.do_test_diff_for_file() + self.assertFalse(os.path.exists(self.bogus_dir)) + + def test_svn_lock(self): + svn_root_lock_path = ".svn/lock" + write_into_file_at_path(svn_root_lock_path, "", "utf-8") + # webkit-patch uses a Checkout object and runs update-webkit, just use svn update here. + self.assertRaises(ScriptError, run_command, ['svn', 'update']) + self.scm.clean_working_directory() + self.assertFalse(os.path.exists(svn_root_lock_path)) + run_command(['svn', 'update']) # Should succeed and not raise. + + def test_exists(self): + self._shared_test_exists(self.scm, self.scm.commit_with_message) + +class GitTest(SCMTest): + + def setUp(self): + """Sets up fresh git repository with one commit. Then setups a second git + repo that tracks the first one.""" + # FIXME: We should instead clone a git repo that is tracking an SVN repo. + # That better matches what we do with WebKit. + self.original_dir = os.getcwd() + + self.untracking_checkout_path = tempfile.mkdtemp(suffix="git_test_checkout2") + run_command(['git', 'init', self.untracking_checkout_path]) + + os.chdir(self.untracking_checkout_path) + write_into_file_at_path('foo_file', 'foo') + run_command(['git', 'add', 'foo_file']) + run_command(['git', 'commit', '-am', 'dummy commit']) + self.untracking_scm = detect_scm_system(self.untracking_checkout_path) + + self.tracking_git_checkout_path = tempfile.mkdtemp(suffix="git_test_checkout") + run_command(['git', 'clone', '--quiet', self.untracking_checkout_path, self.tracking_git_checkout_path]) + os.chdir(self.tracking_git_checkout_path) + self.tracking_scm = detect_scm_system(self.tracking_git_checkout_path) + + def tearDown(self): + # Change back to a valid directory so that later calls to os.getcwd() do not fail. + os.chdir(self.original_dir) + run_command(['rm', '-rf', self.tracking_git_checkout_path]) + run_command(['rm', '-rf', self.untracking_checkout_path]) + + def test_remote_branch_ref(self): + self.assertEqual(self.tracking_scm.remote_branch_ref(), 'refs/remotes/origin/master') + + os.chdir(self.untracking_checkout_path) + self.assertRaises(ScriptError, self.untracking_scm.remote_branch_ref) + + def test_multiple_remotes(self): + run_command(['git', 'config', '--add', 'svn-remote.svn.fetch', 'trunk:remote1']) + run_command(['git', 'config', '--add', 'svn-remote.svn.fetch', 'trunk:remote2']) + self.assertEqual(self.tracking_scm.remote_branch_ref(), 'remote1') + + def test_create_patch(self): + write_into_file_at_path('test_file_commit1', 'contents') + run_command(['git', 'add', 'test_file_commit1']) + scm = self.tracking_scm + scm.commit_locally_with_message('message') + + patch = scm.create_patch() + self.assertFalse(re.search(r'Subversion Revision:', patch)) + + def test_orderfile(self): + os.mkdir("Tools") + os.mkdir("Source") + os.mkdir("LayoutTests") + os.mkdir("Websites") + + # Slash should always be the right path separator since we use cygwin on Windows. + Tools_ChangeLog = "Tools/ChangeLog" + write_into_file_at_path(Tools_ChangeLog, "contents") + Source_ChangeLog = "Source/ChangeLog" + write_into_file_at_path(Source_ChangeLog, "contents") + LayoutTests_ChangeLog = "LayoutTests/ChangeLog" + write_into_file_at_path(LayoutTests_ChangeLog, "contents") + Websites_ChangeLog = "Websites/ChangeLog" + write_into_file_at_path(Websites_ChangeLog, "contents") + + Tools_ChangeFile = "Tools/ChangeFile" + write_into_file_at_path(Tools_ChangeFile, "contents") + Source_ChangeFile = "Source/ChangeFile" + write_into_file_at_path(Source_ChangeFile, "contents") + LayoutTests_ChangeFile = "LayoutTests/ChangeFile" + write_into_file_at_path(LayoutTests_ChangeFile, "contents") + Websites_ChangeFile = "Websites/ChangeFile" + write_into_file_at_path(Websites_ChangeFile, "contents") + + run_command(['git', 'add', 'Tools/ChangeLog']) + run_command(['git', 'add', 'LayoutTests/ChangeLog']) + run_command(['git', 'add', 'Source/ChangeLog']) + run_command(['git', 'add', 'Websites/ChangeLog']) + run_command(['git', 'add', 'Tools/ChangeFile']) + run_command(['git', 'add', 'LayoutTests/ChangeFile']) + run_command(['git', 'add', 'Source/ChangeFile']) + run_command(['git', 'add', 'Websites/ChangeFile']) + scm = self.tracking_scm + scm.commit_locally_with_message('message') + + patch = scm.create_patch() + self.assertTrue(re.search(r'Tools/ChangeLog', patch).start() < re.search(r'Tools/ChangeFile', patch).start()) + self.assertTrue(re.search(r'Websites/ChangeLog', patch).start() < re.search(r'Websites/ChangeFile', patch).start()) + self.assertTrue(re.search(r'Source/ChangeLog', patch).start() < re.search(r'Source/ChangeFile', patch).start()) + self.assertTrue(re.search(r'LayoutTests/ChangeLog', patch).start() < re.search(r'LayoutTests/ChangeFile', patch).start()) + + self.assertTrue(re.search(r'Source/ChangeLog', patch).start() < re.search(r'LayoutTests/ChangeLog', patch).start()) + self.assertTrue(re.search(r'Tools/ChangeLog', patch).start() < re.search(r'LayoutTests/ChangeLog', patch).start()) + self.assertTrue(re.search(r'Websites/ChangeLog', patch).start() < re.search(r'LayoutTests/ChangeLog', patch).start()) + + self.assertTrue(re.search(r'Source/ChangeFile', patch).start() < re.search(r'LayoutTests/ChangeLog', patch).start()) + self.assertTrue(re.search(r'Tools/ChangeFile', patch).start() < re.search(r'LayoutTests/ChangeLog', patch).start()) + self.assertTrue(re.search(r'Websites/ChangeFile', patch).start() < re.search(r'LayoutTests/ChangeLog', patch).start()) + + self.assertTrue(re.search(r'Source/ChangeFile', patch).start() < re.search(r'LayoutTests/ChangeFile', patch).start()) + self.assertTrue(re.search(r'Tools/ChangeFile', patch).start() < re.search(r'LayoutTests/ChangeFile', patch).start()) + self.assertTrue(re.search(r'Websites/ChangeFile', patch).start() < re.search(r'LayoutTests/ChangeFile', patch).start()) + + def test_exists(self): + scm = self.untracking_scm + self._shared_test_exists(scm, scm.commit_locally_with_message) + + def test_head_svn_revision(self): + scm = detect_scm_system(self.untracking_checkout_path) + # If we cloned a git repo tracking an SVG repo, this would give the same result as + # self._shared_test_head_svn_revision(). + self.assertEqual(scm.head_svn_revision(), '') + + def test_rename_files(self): + scm = self.tracking_scm + + run_command(['git', 'mv', 'foo_file', 'bar_file']) + scm.commit_locally_with_message('message') + + patch = scm.create_patch() + self.assertFalse(re.search(r'rename from ', patch)) + self.assertFalse(re.search(r'rename to ', patch)) + + +class GitSVNTest(SCMTest): + + def _setup_git_checkout(self): + self.git_checkout_path = tempfile.mkdtemp(suffix="git_test_checkout") + # --quiet doesn't make git svn silent, so we use run_silent to redirect output + run_silent(['git', 'svn', 'clone', '-T', 'trunk', self.svn_repo_url, self.git_checkout_path]) + os.chdir(self.git_checkout_path) + + def _tear_down_git_checkout(self): + # Change back to a valid directory so that later calls to os.getcwd() do not fail. + os.chdir(self.original_dir) + run_command(['rm', '-rf', self.git_checkout_path]) + + def setUp(self): + self.original_dir = os.getcwd() + + SVNTestRepository.setup(self) + self._setup_git_checkout() + self.scm = detect_scm_system(self.git_checkout_path) + # For historical reasons, we test some checkout code here too. + self.checkout = Checkout(self.scm) + + def tearDown(self): + SVNTestRepository.tear_down(self) + self._tear_down_git_checkout() + + def test_detection(self): + scm = detect_scm_system(self.git_checkout_path) + self.assertEqual(scm.display_name(), "git") + self.assertEqual(scm.supports_local_commits(), True) + + def test_read_git_config(self): + key = 'test.git-config' + value = 'git-config value' + run_command(['git', 'config', key, value]) + self.assertEqual(self.scm.read_git_config(key), value) + + def test_local_commits(self): + test_file = os.path.join(self.git_checkout_path, 'test_file') + write_into_file_at_path(test_file, 'foo') + run_command(['git', 'commit', '-a', '-m', 'local commit']) + + self.assertEqual(len(self.scm.local_commits()), 1) + + def test_discard_local_commits(self): + test_file = os.path.join(self.git_checkout_path, 'test_file') + write_into_file_at_path(test_file, 'foo') + run_command(['git', 'commit', '-a', '-m', 'local commit']) + + self.assertEqual(len(self.scm.local_commits()), 1) + self.scm.discard_local_commits() + self.assertEqual(len(self.scm.local_commits()), 0) + + def test_delete_branch(self): + new_branch = 'foo' + + run_command(['git', 'checkout', '-b', new_branch]) + self.assertEqual(run_command(['git', 'symbolic-ref', 'HEAD']).strip(), 'refs/heads/' + new_branch) + + run_command(['git', 'checkout', '-b', 'bar']) + self.scm.delete_branch(new_branch) + + self.assertFalse(re.search(r'foo', run_command(['git', 'branch']))) + + def test_remote_merge_base(self): + # Diff to merge-base should include working-copy changes, + # which the diff to svn_branch.. doesn't. + test_file = os.path.join(self.git_checkout_path, 'test_file') + write_into_file_at_path(test_file, 'foo') + + diff_to_common_base = _git_diff(self.scm.remote_branch_ref() + '..') + diff_to_merge_base = _git_diff(self.scm.remote_merge_base()) + + self.assertFalse(re.search(r'foo', diff_to_common_base)) + self.assertTrue(re.search(r'foo', diff_to_merge_base)) + + def test_rebase_in_progress(self): + svn_test_file = os.path.join(self.svn_checkout_path, 'test_file') + write_into_file_at_path(svn_test_file, "svn_checkout") + run_command(['svn', 'commit', '--message', 'commit to conflict with git commit'], cwd=self.svn_checkout_path) + + git_test_file = os.path.join(self.git_checkout_path, 'test_file') + write_into_file_at_path(git_test_file, "git_checkout") + run_command(['git', 'commit', '-a', '-m', 'commit to be thrown away by rebase abort']) + + # --quiet doesn't make git svn silent, so use run_silent to redirect output + self.assertRaises(ScriptError, run_silent, ['git', 'svn', '--quiet', 'rebase']) # Will fail due to a conflict leaving us mid-rebase. + + scm = detect_scm_system(self.git_checkout_path) + self.assertTrue(scm.rebase_in_progress()) + + # Make sure our cleanup works. + scm.clean_working_directory() + self.assertFalse(scm.rebase_in_progress()) + + # Make sure cleanup doesn't throw when no rebase is in progress. + scm.clean_working_directory() + + def test_commitish_parsing(self): + scm = detect_scm_system(self.git_checkout_path) + + # Multiple revisions are cherry-picked. + self.assertEqual(len(scm.commit_ids_from_commitish_arguments(['HEAD~2'])), 1) + self.assertEqual(len(scm.commit_ids_from_commitish_arguments(['HEAD', 'HEAD~2'])), 2) + + # ... is an invalid range specifier + self.assertRaises(ScriptError, scm.commit_ids_from_commitish_arguments, ['trunk...HEAD']) + + def test_commitish_order(self): + scm = detect_scm_system(self.git_checkout_path) + + commit_range = 'HEAD~3..HEAD' + + actual_commits = scm.commit_ids_from_commitish_arguments([commit_range]) + expected_commits = [] + expected_commits += reversed(run_command(['git', 'rev-list', commit_range]).splitlines()) + + self.assertEqual(actual_commits, expected_commits) + + def test_apply_git_patch(self): + scm = detect_scm_system(self.git_checkout_path) + # We carefullly pick a diff which does not have a directory addition + # as currently svn-apply will error out when trying to remove directories + # in Git: https://bugs.webkit.org/show_bug.cgi?id=34871 + patch = self._create_patch(_git_diff('HEAD..HEAD^')) + self._setup_webkittools_scripts_symlink(scm) + Checkout(scm).apply_patch(patch) + + def test_apply_git_patch_force(self): + scm = detect_scm_system(self.git_checkout_path) + patch = self._create_patch(_git_diff('HEAD~2..HEAD')) + self._setup_webkittools_scripts_symlink(scm) + self.assertRaises(ScriptError, Checkout(scm).apply_patch, patch, force=True) + + def test_commit_text_parsing(self): + write_into_file_at_path('test_file', 'more test content') + commit_text = self.scm.commit_with_message("another test commit") + self.assertEqual(self.scm.svn_revision_from_commit_text(commit_text), '6') + + self.scm.dryrun = True + write_into_file_at_path('test_file', 'still more test content') + commit_text = self.scm.commit_with_message("yet another test commit") + self.assertEqual(self.scm.svn_revision_from_commit_text(commit_text), '0') + + def test_commit_with_message_working_copy_only(self): + write_into_file_at_path('test_file_commit1', 'more test content') + run_command(['git', 'add', 'test_file_commit1']) + scm = detect_scm_system(self.git_checkout_path) + commit_text = scm.commit_with_message("yet another test commit") + + self.assertEqual(scm.svn_revision_from_commit_text(commit_text), '6') + svn_log = run_command(['git', 'svn', 'log', '--limit=1', '--verbose']) + self.assertTrue(re.search(r'test_file_commit1', svn_log)) + + def _local_commit(self, filename, contents, message): + write_into_file_at_path(filename, contents) + run_command(['git', 'add', filename]) + self.scm.commit_locally_with_message(message) + + def _one_local_commit(self): + self._local_commit('test_file_commit1', 'more test content', 'another test commit') + + def _one_local_commit_plus_working_copy_changes(self): + self._one_local_commit() + write_into_file_at_path('test_file_commit2', 'still more test content') + run_command(['git', 'add', 'test_file_commit2']) + + def _two_local_commits(self): + self._one_local_commit() + self._local_commit('test_file_commit2', 'still more test content', 'yet another test commit') + + def _three_local_commits(self): + self._local_commit('test_file_commit0', 'more test content', 'another test commit') + self._two_local_commits() + + def test_revisions_changing_files_with_local_commit(self): + self._one_local_commit() + self.assertEquals(self.scm.revisions_changing_file('test_file_commit1'), []) + + def test_commit_with_message(self): + self._one_local_commit_plus_working_copy_changes() + scm = detect_scm_system(self.git_checkout_path) + self.assertRaises(AmbiguousCommitError, scm.commit_with_message, "yet another test commit") + commit_text = scm.commit_with_message("yet another test commit", force_squash=True) + + self.assertEqual(scm.svn_revision_from_commit_text(commit_text), '6') + svn_log = run_command(['git', 'svn', 'log', '--limit=1', '--verbose']) + self.assertTrue(re.search(r'test_file_commit2', svn_log)) + self.assertTrue(re.search(r'test_file_commit1', svn_log)) + + def test_commit_with_message_git_commit(self): + self._two_local_commits() + + scm = detect_scm_system(self.git_checkout_path) + commit_text = scm.commit_with_message("another test commit", git_commit="HEAD^") + self.assertEqual(scm.svn_revision_from_commit_text(commit_text), '6') + + svn_log = run_command(['git', 'svn', 'log', '--limit=1', '--verbose']) + self.assertTrue(re.search(r'test_file_commit1', svn_log)) + self.assertFalse(re.search(r'test_file_commit2', svn_log)) + + def test_commit_with_message_git_commit_range(self): + self._three_local_commits() + + scm = detect_scm_system(self.git_checkout_path) + commit_text = scm.commit_with_message("another test commit", git_commit="HEAD~2..HEAD") + self.assertEqual(scm.svn_revision_from_commit_text(commit_text), '6') + + svn_log = run_command(['git', 'svn', 'log', '--limit=1', '--verbose']) + self.assertFalse(re.search(r'test_file_commit0', svn_log)) + self.assertTrue(re.search(r'test_file_commit1', svn_log)) + self.assertTrue(re.search(r'test_file_commit2', svn_log)) + + def test_changed_files_working_copy_only(self): + self._one_local_commit_plus_working_copy_changes() + scm = detect_scm_system(self.git_checkout_path) + commit_text = scm.commit_with_message("another test commit", git_commit="HEAD..") + self.assertFalse(re.search(r'test_file_commit1', svn_log)) + self.assertTrue(re.search(r'test_file_commit2', svn_log)) + + def test_commit_with_message_only_local_commit(self): + self._one_local_commit() + scm = detect_scm_system(self.git_checkout_path) + commit_text = scm.commit_with_message("another test commit") + svn_log = run_command(['git', 'svn', 'log', '--limit=1', '--verbose']) + self.assertTrue(re.search(r'test_file_commit1', svn_log)) + + def test_commit_with_message_multiple_local_commits_and_working_copy(self): + self._two_local_commits() + write_into_file_at_path('test_file_commit1', 'working copy change') + scm = detect_scm_system(self.git_checkout_path) + + self.assertRaises(AmbiguousCommitError, scm.commit_with_message, "another test commit") + commit_text = scm.commit_with_message("another test commit", force_squash=True) + + self.assertEqual(scm.svn_revision_from_commit_text(commit_text), '6') + svn_log = run_command(['git', 'svn', 'log', '--limit=1', '--verbose']) + self.assertTrue(re.search(r'test_file_commit2', svn_log)) + self.assertTrue(re.search(r'test_file_commit1', svn_log)) + + def test_commit_with_message_git_commit_and_working_copy(self): + self._two_local_commits() + write_into_file_at_path('test_file_commit1', 'working copy change') + scm = detect_scm_system(self.git_checkout_path) + self.assertRaises(ScriptError, scm.commit_with_message, "another test commit", git_commit="HEAD^") + + def test_commit_with_message_multiple_local_commits_always_squash(self): + self._two_local_commits() + scm = detect_scm_system(self.git_checkout_path) + scm._assert_can_squash = lambda working_directory_is_clean: True + commit_text = scm.commit_with_message("yet another test commit") + self.assertEqual(scm.svn_revision_from_commit_text(commit_text), '6') + + svn_log = run_command(['git', 'svn', 'log', '--limit=1', '--verbose']) + self.assertTrue(re.search(r'test_file_commit2', svn_log)) + self.assertTrue(re.search(r'test_file_commit1', svn_log)) + + def test_commit_with_message_multiple_local_commits(self): + self._two_local_commits() + scm = detect_scm_system(self.git_checkout_path) + self.assertRaises(AmbiguousCommitError, scm.commit_with_message, "yet another test commit") + commit_text = scm.commit_with_message("yet another test commit", force_squash=True) + + self.assertEqual(scm.svn_revision_from_commit_text(commit_text), '6') + + svn_log = run_command(['git', 'svn', 'log', '--limit=1', '--verbose']) + self.assertTrue(re.search(r'test_file_commit2', svn_log)) + self.assertTrue(re.search(r'test_file_commit1', svn_log)) + + def test_commit_with_message_not_synced(self): + run_command(['git', 'checkout', '-b', 'my-branch', 'trunk~3']) + self._two_local_commits() + scm = detect_scm_system(self.git_checkout_path) + self.assertRaises(AmbiguousCommitError, scm.commit_with_message, "another test commit") + commit_text = scm.commit_with_message("another test commit", force_squash=True) + + self.assertEqual(scm.svn_revision_from_commit_text(commit_text), '6') + + svn_log = run_command(['git', 'svn', 'log', '--limit=1', '--verbose']) + self.assertFalse(re.search(r'test_file2', svn_log)) + self.assertTrue(re.search(r'test_file_commit2', svn_log)) + self.assertTrue(re.search(r'test_file_commit1', svn_log)) + + def test_commit_with_message_not_synced_with_conflict(self): + run_command(['git', 'checkout', '-b', 'my-branch', 'trunk~3']) + self._local_commit('test_file2', 'asdf', 'asdf commit') + + scm = detect_scm_system(self.git_checkout_path) + # There's a conflict between trunk and the test_file2 modification. + self.assertRaises(ScriptError, scm.commit_with_message, "another test commit", force_squash=True) + + def test_remote_branch_ref(self): + self.assertEqual(self.scm.remote_branch_ref(), 'refs/remotes/trunk') + + def test_reverse_diff(self): + self._shared_test_reverse_diff() + + def test_diff_for_revision(self): + self._shared_test_diff_for_revision() + + def test_svn_apply_git_patch(self): + self._shared_test_svn_apply_git_patch() + + def test_create_patch_local_plus_working_copy(self): + self._one_local_commit_plus_working_copy_changes() + scm = detect_scm_system(self.git_checkout_path) + patch = scm.create_patch() + self.assertTrue(re.search(r'test_file_commit1', patch)) + self.assertTrue(re.search(r'test_file_commit2', patch)) + + def test_create_patch(self): + self._one_local_commit_plus_working_copy_changes() + scm = detect_scm_system(self.git_checkout_path) + patch = scm.create_patch() + self.assertTrue(re.search(r'test_file_commit2', patch)) + self.assertTrue(re.search(r'test_file_commit1', patch)) + self.assertTrue(re.search(r'Subversion Revision: 5', patch)) + + def test_create_patch_after_merge(self): + run_command(['git', 'checkout', '-b', 'dummy-branch', 'trunk~3']) + self._one_local_commit() + run_command(['git', 'merge', 'trunk']) + + scm = detect_scm_system(self.git_checkout_path) + patch = scm.create_patch() + self.assertTrue(re.search(r'test_file_commit1', patch)) + self.assertTrue(re.search(r'Subversion Revision: 5', patch)) + + def test_create_patch_with_changed_files(self): + self._one_local_commit_plus_working_copy_changes() + scm = detect_scm_system(self.git_checkout_path) + patch = scm.create_patch(changed_files=['test_file_commit2']) + self.assertTrue(re.search(r'test_file_commit2', patch)) + + def test_create_patch_with_rm_and_changed_files(self): + self._one_local_commit_plus_working_copy_changes() + scm = detect_scm_system(self.git_checkout_path) + os.remove('test_file_commit1') + patch = scm.create_patch() + patch_with_changed_files = scm.create_patch(changed_files=['test_file_commit1', 'test_file_commit2']) + self.assertEquals(patch, patch_with_changed_files) + + def test_create_patch_git_commit(self): + self._two_local_commits() + scm = detect_scm_system(self.git_checkout_path) + patch = scm.create_patch(git_commit="HEAD^") + self.assertTrue(re.search(r'test_file_commit1', patch)) + self.assertFalse(re.search(r'test_file_commit2', patch)) + + def test_create_patch_git_commit_range(self): + self._three_local_commits() + scm = detect_scm_system(self.git_checkout_path) + patch = scm.create_patch(git_commit="HEAD~2..HEAD") + self.assertFalse(re.search(r'test_file_commit0', patch)) + self.assertTrue(re.search(r'test_file_commit2', patch)) + self.assertTrue(re.search(r'test_file_commit1', patch)) + + def test_create_patch_working_copy_only(self): + self._one_local_commit_plus_working_copy_changes() + scm = detect_scm_system(self.git_checkout_path) + patch = scm.create_patch(git_commit="HEAD..") + self.assertFalse(re.search(r'test_file_commit1', patch)) + self.assertTrue(re.search(r'test_file_commit2', patch)) + + def test_create_patch_multiple_local_commits(self): + self._two_local_commits() + scm = detect_scm_system(self.git_checkout_path) + patch = scm.create_patch() + self.assertTrue(re.search(r'test_file_commit2', patch)) + self.assertTrue(re.search(r'test_file_commit1', patch)) + + def test_create_patch_not_synced(self): + run_command(['git', 'checkout', '-b', 'my-branch', 'trunk~3']) + self._two_local_commits() + scm = detect_scm_system(self.git_checkout_path) + patch = scm.create_patch() + self.assertFalse(re.search(r'test_file2', patch)) + self.assertTrue(re.search(r'test_file_commit2', patch)) + self.assertTrue(re.search(r'test_file_commit1', patch)) + + def test_create_binary_patch(self): + # Create a git binary patch and check the contents. + scm = detect_scm_system(self.git_checkout_path) + test_file_name = 'binary_file' + test_file_path = os.path.join(self.git_checkout_path, test_file_name) + file_contents = ''.join(map(chr, range(256))) + write_into_file_at_path(test_file_path, file_contents, encoding=None) + run_command(['git', 'add', test_file_name]) + patch = scm.create_patch() + self.assertTrue(re.search(r'\nliteral 0\n', patch)) + self.assertTrue(re.search(r'\nliteral 256\n', patch)) + + # Check if we can apply the created patch. + run_command(['git', 'rm', '-f', test_file_name]) + self._setup_webkittools_scripts_symlink(scm) + self.checkout.apply_patch(self._create_patch(patch)) + self.assertEqual(file_contents, read_from_path(test_file_path, encoding=None)) + + # Check if we can create a patch from a local commit. + write_into_file_at_path(test_file_path, file_contents, encoding=None) + run_command(['git', 'add', test_file_name]) + run_command(['git', 'commit', '-m', 'binary diff']) + patch_from_local_commit = scm.create_patch('HEAD') + self.assertTrue(re.search(r'\nliteral 0\n', patch_from_local_commit)) + self.assertTrue(re.search(r'\nliteral 256\n', patch_from_local_commit)) + + def test_changed_files_local_plus_working_copy(self): + self._one_local_commit_plus_working_copy_changes() + scm = detect_scm_system(self.git_checkout_path) + files = scm.changed_files() + self.assertTrue('test_file_commit1' in files) + self.assertTrue('test_file_commit2' in files) + + def test_changed_files_git_commit(self): + self._two_local_commits() + scm = detect_scm_system(self.git_checkout_path) + files = scm.changed_files(git_commit="HEAD^") + self.assertTrue('test_file_commit1' in files) + self.assertFalse('test_file_commit2' in files) + + def test_changed_files_git_commit_range(self): + self._three_local_commits() + scm = detect_scm_system(self.git_checkout_path) + files = scm.changed_files(git_commit="HEAD~2..HEAD") + self.assertTrue('test_file_commit0' not in files) + self.assertTrue('test_file_commit1' in files) + self.assertTrue('test_file_commit2' in files) + + def test_changed_files_working_copy_only(self): + self._one_local_commit_plus_working_copy_changes() + scm = detect_scm_system(self.git_checkout_path) + files = scm.changed_files(git_commit="HEAD..") + self.assertFalse('test_file_commit1' in files) + self.assertTrue('test_file_commit2' in files) + + def test_changed_files_multiple_local_commits(self): + self._two_local_commits() + scm = detect_scm_system(self.git_checkout_path) + files = scm.changed_files() + self.assertTrue('test_file_commit2' in files) + self.assertTrue('test_file_commit1' in files) + + def test_changed_files_not_synced(self): + run_command(['git', 'checkout', '-b', 'my-branch', 'trunk~3']) + self._two_local_commits() + scm = detect_scm_system(self.git_checkout_path) + files = scm.changed_files() + self.assertFalse('test_file2' in files) + self.assertTrue('test_file_commit2' in files) + self.assertTrue('test_file_commit1' in files) + + def test_changed_files_not_synced(self): + run_command(['git', 'checkout', '-b', 'my-branch', 'trunk~3']) + self._two_local_commits() + scm = detect_scm_system(self.git_checkout_path) + files = scm.changed_files() + self.assertFalse('test_file2' in files) + self.assertTrue('test_file_commit2' in files) + self.assertTrue('test_file_commit1' in files) + + def test_changed_files(self): + self._shared_test_changed_files() + + def test_changed_files_for_revision(self): + self._shared_test_changed_files_for_revision() + + def test_contents_at_revision(self): + self._shared_test_contents_at_revision() + + def test_revisions_changing_file(self): + self._shared_test_revisions_changing_file() + + def test_added_files(self): + self._shared_test_added_files() + + def test_committer_email_for_revision(self): + self._shared_test_committer_email_for_revision() + + def test_add_recursively(self): + self._shared_test_add_recursively() + + def test_delete(self): + self._two_local_commits() + self.scm.delete('test_file_commit1') + self.assertTrue("test_file_commit1" in self.scm.deleted_files()) + + def test_delete_recursively(self): + self._shared_test_delete_recursively() + + def test_delete_recursively_or_not(self): + self._shared_test_delete_recursively_or_not() + + def test_head_svn_revision(self): + self._shared_test_head_svn_revision() + + def test_to_object_name(self): + relpath = 'test_file_commit1' + fullpath = os.path.join(self.git_checkout_path, relpath) + self._two_local_commits() + self.assertEqual(relpath, self.scm.to_object_name(fullpath)) + + def test_show_head(self): + self._two_local_commits() + self.assertEqual("more test content", self.scm.show_head('test_file_commit1')) + + def test_show_head_binary(self): + self._two_local_commits() + data = "\244" + write_into_file_at_path("binary_file", data, encoding=None) + self.scm.add("binary_file") + self.scm.commit_locally_with_message("a test commit") + self.assertEqual(data, self.scm.show_head('binary_file')) + + def test_diff_for_file(self): + self._two_local_commits() + write_into_file_at_path('test_file_commit1', "Updated", encoding=None) + + diff = self.scm.diff_for_file('test_file_commit1') + cached_diff = self.scm.diff_for_file('test_file_commit1') + self.assertTrue("+Updated" in diff) + self.assertTrue("-more test content" in diff) + + self.scm.add('test_file_commit1') + + cached_diff = self.scm.diff_for_file('test_file_commit1') + self.assertTrue("+Updated" in cached_diff) + self.assertTrue("-more test content" in cached_diff) + + def test_exists(self): + scm = detect_scm_system(self.git_checkout_path) + self._shared_test_exists(scm, scm.commit_locally_with_message) + + +# We need to split off more of these SCM tests to use mocks instead of the filesystem. +# This class is the first part of that. +class GitTestWithMock(unittest.TestCase): + def make_scm(self, logging_executive=False): + # We do this should_log dance to avoid logging when Git.__init__ runs sysctl on mac to check for 64-bit support. + scm = Git(cwd=None, executive=MockExecutive()) + scm._executive._should_log = logging_executive + return scm + + def test_create_patch(self): + scm = self.make_scm(logging_executive=True) + expected_stderr = "MOCK run_command: ['git', 'merge-base', u'refs/remotes/origin/master', 'HEAD'], cwd=%(checkout)s\nMOCK run_command: ['git', 'diff', '--binary', '--no-ext-diff', '--full-index', '-M', 'MOCK output of child process', '--'], cwd=%(checkout)s\nMOCK run_command: ['git', 'log', '-25'], cwd=None\n" % {'checkout': scm.checkout_root} + OutputCapture().assert_outputs(self, scm.create_patch, expected_stderr=expected_stderr) + + def test_push_local_commits_to_server_with_username_and_password(self): + self.assertEquals(self.make_scm().push_local_commits_to_server(username='dbates@webkit.org', password='blah'), "MOCK output of child process") + + def test_push_local_commits_to_server_without_username_and_password(self): + self.assertRaises(AuthenticationError, self.make_scm().push_local_commits_to_server) + + def test_push_local_commits_to_server_with_username_and_without_password(self): + self.assertRaises(AuthenticationError, self.make_scm().push_local_commits_to_server, {'username': 'dbates@webkit.org'}) + + def test_push_local_commits_to_server_without_username_and_with_password(self): + self.assertRaises(AuthenticationError, self.make_scm().push_local_commits_to_server, {'password': 'blah'}) + +if __name__ == '__main__': + unittest.main() diff --git a/Tools/Scripts/webkitpy/common/checkout/scm/svn.py b/Tools/Scripts/webkitpy/common/checkout/scm/svn.py new file mode 100644 index 000000000..cd4e1ea60 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/scm/svn.py @@ -0,0 +1,362 @@ +# Copyright (c) 2009, 2010, 2011 Google Inc. All rights reserved. +# Copyright (c) 2009 Apple Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import logging +import os +import re +import shutil +import sys + +from webkitpy.common.memoized import memoized +from webkitpy.common.system.deprecated_logging import log +from webkitpy.common.system.executive import Executive, ScriptError +from webkitpy.common.system import ospath + +from .scm import AuthenticationError, SCM, commit_error_handler + + +_log = logging.getLogger(__name__) + + +# A mixin class that represents common functionality for SVN and Git-SVN. +class SVNRepository: + def has_authorization_for_realm(self, realm, home_directory=os.getenv("HOME")): + # Assumes find and grep are installed. + if not os.path.isdir(os.path.join(home_directory, ".subversion")): + return False + find_args = ["find", ".subversion", "-type", "f", "-exec", "grep", "-q", realm, "{}", ";", "-print"] + find_output = self.run(find_args, cwd=home_directory, error_handler=Executive.ignore_error).rstrip() + if not find_output or not os.path.isfile(os.path.join(home_directory, find_output)): + return False + # Subversion either stores the password in the credential file, indicated by the presence of the key "password", + # or uses the system password store (e.g. Keychain on Mac OS X) as indicated by the presence of the key "passtype". + # We assume that these keys will not coincide with the actual credential data (e.g. that a person's username + # isn't "password") so that we can use grep. + if self.run(["grep", "password", find_output], cwd=home_directory, return_exit_code=True) == 0: + return True + return self.run(["grep", "passtype", find_output], cwd=home_directory, return_exit_code=True) == 0 + + +class SVN(SCM, SVNRepository): + # FIXME: These belong in common.config.urls + svn_server_host = "svn.webkit.org" + svn_server_realm = "<http://svn.webkit.org:80> Mac OS Forge" + + executable_name = "svn" + + _svn_metadata_files = frozenset(['.svn', '_svn']) + + def __init__(self, cwd, patch_directories, **kwargs): + SCM.__init__(self, cwd, **kwargs) + self._bogus_dir = None + if patch_directories == []: + # FIXME: ScriptError is for Executive, this should probably be a normal Exception. + raise ScriptError(script_args=svn_info_args, message='Empty list of patch directories passed to SCM.__init__') + elif patch_directories == None: + self._patch_directories = [self._filesystem.relpath(cwd, self.checkout_root)] + else: + self._patch_directories = patch_directories + + @staticmethod + def in_working_directory(path): + return os.path.isdir(os.path.join(path, '.svn')) + + @classmethod + def find_uuid(cls, path): + if not cls.in_working_directory(path): + return None + return cls.value_from_svn_info(path, 'Repository UUID') + + @classmethod + def value_from_svn_info(cls, path, field_name): + svn_info_args = [cls.executable_name, 'info'] + # FIXME: This method should use a passed in executive or be made an instance method and use self._executive. + info_output = Executive().run_command(svn_info_args, cwd=path).rstrip() + match = re.search("^%s: (?P<value>.+)$" % field_name, info_output, re.MULTILINE) + if not match: + raise ScriptError(script_args=svn_info_args, message='svn info did not contain a %s.' % field_name) + return match.group('value') + + @staticmethod + def find_checkout_root(path): + uuid = SVN.find_uuid(path) + # If |path| is not in a working directory, we're supposed to return |path|. + if not uuid: + return path + # Search up the directory hierarchy until we find a different UUID. + last_path = None + while True: + if uuid != SVN.find_uuid(path): + return last_path + last_path = path + (path, last_component) = os.path.split(path) + if last_path == path: + return None + + @staticmethod + def commit_success_regexp(): + return "^Committed revision (?P<svn_revision>\d+)\.$" + + def _run_svn(self, args, **kwargs): + return self.run([self.executable_name] + args, **kwargs) + + @memoized + def svn_version(self): + return self._run_svn(['--version', '--quiet']) + + def working_directory_is_clean(self): + return self._run_svn(["diff"], cwd=self.checkout_root, decode_output=False) == "" + + def clean_working_directory(self): + # Make sure there are no locks lying around from a previously aborted svn invocation. + # This is slightly dangerous, as it's possible the user is running another svn process + # on this checkout at the same time. However, it's much more likely that we're running + # under windows and svn just sucks (or the user interrupted svn and it failed to clean up). + self._run_svn(["cleanup"], cwd=self.checkout_root) + + # svn revert -R is not as awesome as git reset --hard. + # It will leave added files around, causing later svn update + # calls to fail on the bots. We make this mirror git reset --hard + # by deleting any added files as well. + added_files = reversed(sorted(self.added_files())) + # added_files() returns directories for SVN, we walk the files in reverse path + # length order so that we remove files before we try to remove the directories. + self._run_svn(["revert", "-R", "."], cwd=self.checkout_root) + for path in added_files: + # This is robust against cwd != self.checkout_root + absolute_path = self.absolute_path(path) + # Completely lame that there is no easy way to remove both types with one call. + if os.path.isdir(path): + os.rmdir(absolute_path) + else: + os.remove(absolute_path) + + def status_command(self): + return [self.executable_name, 'status'] + + def _status_regexp(self, expected_types): + field_count = 6 if self.svn_version() > "1.6" else 5 + return "^(?P<status>[%s]).{%s} (?P<filename>.+)$" % (expected_types, field_count) + + def _add_parent_directories(self, path): + """Does 'svn add' to the path and its parents.""" + if self.in_working_directory(path): + return + dirname = os.path.dirname(path) + # We have dirname directry - ensure it added. + if dirname != path: + self._add_parent_directories(dirname) + self.add(path) + + def add(self, path, return_exit_code=False): + self._add_parent_directories(os.path.dirname(os.path.abspath(path))) + return self._run_svn(["add", path], return_exit_code=return_exit_code) + + def _delete_parent_directories(self, path): + if not self.in_working_directory(path): + return + if set(os.listdir(path)) - self._svn_metadata_files: + return # Directory has non-trivial files in it. + self.delete(path) + dirname = os.path.dirname(path) + if dirname != path: + self._delete_parent_directories(dirname) + + def delete(self, path): + abs_path = os.path.abspath(path) + parent, base = os.path.split(abs_path) + result = self._run_svn(["delete", "--force", base], cwd=parent) + self._delete_parent_directories(os.path.dirname(abs_path)) + return result + + def exists(self, path): + return not self._run_svn(["info", path], return_exit_code=True, decode_output=False) + + def changed_files(self, git_commit=None): + status_command = [self.executable_name, "status"] + status_command.extend(self._patch_directories) + # ACDMR: Addded, Conflicted, Deleted, Modified or Replaced + return self.run_status_and_extract_filenames(status_command, self._status_regexp("ACDMR")) + + def changed_files_for_revision(self, revision): + # As far as I can tell svn diff --summarize output looks just like svn status output. + # No file contents printed, thus utf-8 auto-decoding in self.run is fine. + status_command = [self.executable_name, "diff", "--summarize", "-c", revision] + return self.run_status_and_extract_filenames(status_command, self._status_regexp("ACDMR")) + + def revisions_changing_file(self, path, limit=5): + revisions = [] + # svn log will exit(1) (and thus self.run will raise) if the path does not exist. + log_command = ['log', '--quiet', '--limit=%s' % limit, path] + for line in self._run_svn(log_command, cwd=self.checkout_root).splitlines(): + match = re.search('^r(?P<revision>\d+) ', line) + if not match: + continue + revisions.append(int(match.group('revision'))) + return revisions + + def conflicted_files(self): + return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("C")) + + def added_files(self): + return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("A")) + + def deleted_files(self): + return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("D")) + + @staticmethod + def supports_local_commits(): + return False + + def display_name(self): + return "svn" + + def head_svn_revision(self): + return self.value_from_svn_info(self.checkout_root, 'Revision') + + # FIXME: This method should be on Checkout. + def create_patch(self, git_commit=None, changed_files=None): + """Returns a byte array (str()) representing the patch file. + Patch files are effectively binary since they may contain + files of multiple different encodings.""" + if changed_files == []: + return "" + elif changed_files == None: + changed_files = [] + return self.run([self.script_path("svn-create-patch")] + changed_files, + cwd=self.checkout_root, return_stderr=False, + decode_output=False) + + def committer_email_for_revision(self, revision): + return self._run_svn(["propget", "svn:author", "--revprop", "-r", revision]).rstrip() + + def contents_at_revision(self, path, revision): + """Returns a byte array (str()) containing the contents + of path @ revision in the repository.""" + remote_path = "%s/%s" % (self._repository_url(), path) + return self._run_svn(["cat", "-r", revision, remote_path], decode_output=False) + + def diff_for_revision(self, revision): + # FIXME: This should probably use cwd=self.checkout_root + return self._run_svn(['diff', '-c', revision]) + + def _bogus_dir_name(self): + if sys.platform.startswith("win"): + parent_dir = tempfile.gettempdir() + else: + parent_dir = sys.path[0] # tempdir is not secure. + return os.path.join(parent_dir, "temp_svn_config") + + def _setup_bogus_dir(self, log): + self._bogus_dir = self._bogus_dir_name() + if not os.path.exists(self._bogus_dir): + os.mkdir(self._bogus_dir) + self._delete_bogus_dir = True + else: + self._delete_bogus_dir = False + if log: + log.debug(' Html: temp config dir: "%s".', self._bogus_dir) + + def _teardown_bogus_dir(self, log): + if self._delete_bogus_dir: + shutil.rmtree(self._bogus_dir, True) + if log: + log.debug(' Html: removed temp config dir: "%s".', self._bogus_dir) + self._bogus_dir = None + + def diff_for_file(self, path, log=None): + self._setup_bogus_dir(log) + try: + args = ['diff'] + if self._bogus_dir: + args += ['--config-dir', self._bogus_dir] + args.append(path) + return self._run_svn(args, cwd=self.checkout_root) + finally: + self._teardown_bogus_dir(log) + + def show_head(self, path): + return self._run_svn(['cat', '-r', 'BASE', path], decode_output=False) + + def _repository_url(self): + return self.value_from_svn_info(self.checkout_root, 'URL') + + def apply_reverse_diff(self, revision): + # '-c -revision' applies the inverse diff of 'revision' + svn_merge_args = ['merge', '--non-interactive', '-c', '-%s' % revision, self._repository_url()] + log("WARNING: svn merge has been known to take more than 10 minutes to complete. It is recommended you use git for rollouts.") + log("Running 'svn %s'" % " ".join(svn_merge_args)) + # FIXME: Should this use cwd=self.checkout_root? + self._run_svn(svn_merge_args) + + def revert_files(self, file_paths): + # FIXME: This should probably use cwd=self.checkout_root. + self._run_svn(['revert'] + file_paths) + + def commit_with_message(self, message, username=None, password=None, git_commit=None, force_squash=False, changed_files=None): + # git-commit and force are not used by SVN. + svn_commit_args = ["commit"] + + if not username and not self.has_authorization_for_realm(self.svn_server_realm): + raise AuthenticationError(self.svn_server_host) + if username: + svn_commit_args.extend(["--username", username]) + + svn_commit_args.extend(["-m", message]) + + if changed_files: + svn_commit_args.extend(changed_files) + + if self.dryrun: + _log.debug('Would run SVN command: "' + " ".join(svn_commit_args) + '"') + + # Return a string which looks like a commit so that things which parse this output will succeed. + return "Dry run, no commit.\nCommitted revision 0." + + return self._run_svn(svn_commit_args, cwd=self.checkout_root, error_handler=commit_error_handler) + + def svn_commit_log(self, svn_revision): + svn_revision = self.strip_r_from_svn_revision(svn_revision) + return self._run_svn(['log', '--non-interactive', '--revision', svn_revision]) + + def last_svn_commit_log(self): + # BASE is the checkout revision, HEAD is the remote repository revision + # http://svnbook.red-bean.com/en/1.0/ch03s03.html + return self.svn_commit_log('BASE') + + def svn_blame(self, path): + return self._run_svn(['blame', path]) + + def propset(self, pname, pvalue, path): + dir, base = os.path.split(path) + return self._run_svn(['pset', pname, pvalue, base], cwd=dir) + + def propget(self, pname, path): + dir, base = os.path.split(path) + return self._run_svn(['pget', pname, base], cwd=dir).encode('utf-8').rstrip("\n") |
