summaryrefslogtreecommitdiff
path: root/Tools/Scripts/webkitpy/common/checkout
diff options
context:
space:
mode:
authorSimon Hausmann <simon.hausmann@nokia.com>2012-01-06 14:44:00 +0100
committerSimon Hausmann <simon.hausmann@nokia.com>2012-01-06 14:44:00 +0100
commit40736c5763bf61337c8c14e16d8587db021a87d4 (patch)
treeb17a9c00042ad89cb1308e2484491799aa14e9f8 /Tools/Scripts/webkitpy/common/checkout
downloadqtwebkit-40736c5763bf61337c8c14e16d8587db021a87d4.tar.gz
Imported WebKit commit 2ea9d364d0f6efa8fa64acf19f451504c59be0e4 (http://svn.webkit.org/repository/webkit/trunk@104285)
Diffstat (limited to 'Tools/Scripts/webkitpy/common/checkout')
-rw-r--r--Tools/Scripts/webkitpy/common/checkout/__init__.py3
-rw-r--r--Tools/Scripts/webkitpy/common/checkout/baselineoptimizer.py163
-rw-r--r--Tools/Scripts/webkitpy/common/checkout/baselineoptimizer_unittest.py157
-rw-r--r--Tools/Scripts/webkitpy/common/checkout/changelog.py366
-rw-r--r--Tools/Scripts/webkitpy/common/checkout/changelog_unittest.py496
-rw-r--r--Tools/Scripts/webkitpy/common/checkout/checkout.py179
-rw-r--r--Tools/Scripts/webkitpy/common/checkout/checkout_mock.py95
-rw-r--r--Tools/Scripts/webkitpy/common/checkout/checkout_unittest.py252
-rw-r--r--Tools/Scripts/webkitpy/common/checkout/commitinfo.py100
-rw-r--r--Tools/Scripts/webkitpy/common/checkout/commitinfo_unittest.py61
-rw-r--r--Tools/Scripts/webkitpy/common/checkout/deps.py61
-rw-r--r--Tools/Scripts/webkitpy/common/checkout/deps_mock.py38
-rw-r--r--Tools/Scripts/webkitpy/common/checkout/diff_parser.py186
-rw-r--r--Tools/Scripts/webkitpy/common/checkout/diff_parser_unittest.py94
-rw-r--r--Tools/Scripts/webkitpy/common/checkout/diff_test_data.py80
-rw-r--r--Tools/Scripts/webkitpy/common/checkout/scm/__init__.py8
-rw-r--r--Tools/Scripts/webkitpy/common/checkout/scm/commitmessage.py62
-rw-r--r--Tools/Scripts/webkitpy/common/checkout/scm/detection.py81
-rw-r--r--Tools/Scripts/webkitpy/common/checkout/scm/git.py481
-rw-r--r--Tools/Scripts/webkitpy/common/checkout/scm/scm.py240
-rw-r--r--Tools/Scripts/webkitpy/common/checkout/scm/scm_mock.py114
-rw-r--r--Tools/Scripts/webkitpy/common/checkout/scm/scm_unittest.py1640
-rw-r--r--Tools/Scripts/webkitpy/common/checkout/scm/svn.py362
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")