Diff
Modified: trunk/Tools/ChangeLog (270037 => 270038)
--- trunk/Tools/ChangeLog 2020-11-19 17:37:49 UTC (rev 270037)
+++ trunk/Tools/ChangeLog 2020-11-19 18:49:36 UTC (rev 270038)
@@ -1,3 +1,56 @@
+2020-11-19 Jonathan Bedard <jbed...@apple.com>
+
+ [webkitscmpy] Support remote Subversion repository
+ https://bugs.webkit.org/show_bug.cgi?id=218827
+ <rdar://problem/71304485>
+
+ Rubber-stamped by Aakash Jain.
+
+ It is possible to interact with a Subversion repository without the svn command. This is useful
+ for services hosted in environments where installing binaries is burdensome, or in cases where
+ tools are running without a checkout.
+
+ * Scripts/libraries/webkitcorepy/webkitcorepy/__init__.py: Bump version, add dependencies.
+ * Scripts/libraries/webkitscmpy/webkitscmpy/mocks/__init__.py:
+ * Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote: Added.
+ * Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/__init__.py: Added.
+ * Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/svn.py: Added.
+ (Svn): Interact with a remote Subversion repository without using the svn command.
+ (Svn.is_webserver): Check if the provided url matches the pattern for a Subversion server.
+ (Svn.__init__):
+ (Svn.is_svn):
+ (Svn._latest): Return the latest revision.
+ (Svn.info): Return the bits of the `svn info` command that are used in code.
+ (Svn.default_branch):
+ (Svn.list):
+ (Svn.branches): List all branches.
+ (Svn.tags): List all tags.
+ (Svn._cache_path): Return path to json cache.
+ (Svn._cache_lock): Filesystem lock used to prevent contention over the json cache, this
+ is particularly important during testing.
+ (Svn._cache_revisions): Cache the identifier/revision mapping.
+ (Svn._branch_for): Given a commit, determine which branch that commit is on.
+ (Svn._commit_count): Determine the amount of commits on a branch since branching.
+ (Svn.commit): Return a commit object given a revision, branch, identifier or tag.
+ * Scripts/libraries/webkitscmpy/webkitscmpy/remote: Added.
+ * Scripts/libraries/webkitscmpy/webkitscmpy/remote/__init__.py: Added.
+ * Scripts/libraries/webkitscmpy/webkitscmpy/remote/scm.py: Added.
+ (Scm):
+ (Scm.from_url): Given a URL, attempt to determine what kind of remote API is available.
+ (Scm.__init__):
+ * Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/svn.py: Added.
+ (Svn): Mock requests needed to interact with a remote Subversion repository.
+ (Svn.latest): Return the most recent commit on any branch.
+ (Svn.branches): Return branches at some revision.
+ (Svn.tags): Return tags at some revision.
+ (Svn.range): Give a start and end revision, along with a tag or branch,
+ return all commits in that range.
+ (Svn.request): Handle a small subset of requests to a Subversion server.
+ * Scripts/libraries/webkitscmpy/webkitscmpy/test/svn_unittest.py:
+ (TestLocalSvn): Moved from TestSvn.
+ (TestRemoteSvn): Added.
+ (TestSvn): Moved to TestLocalSvn.
+
2020-11-19 Per Arne Vollan <pvol...@apple.com>
[macOS] Issue sandbox extension to Web Inspector service
Modified: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/__init__.py (270037 => 270038)
--- trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/__init__.py 2020-11-19 17:37:49 UTC (rev 270037)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/__init__.py 2020-11-19 18:49:36 UTC (rev 270038)
@@ -46,9 +46,12 @@
"Please install webkitcorepy with `pip install webkitcorepy --extra-index-url <package index URL>`"
)
-version = Version(0, 3, 2)
+version = Version(0, 4, 0)
AutoInstall.register(Package('dateutil', Version(2, 8, 1), pypi_name='python-dateutil'))
+AutoInstall.register(Package('fasteners', Version(0, 15, 0)))
+AutoInstall.register(Package('monotonic', Version(1, 5)))
+AutoInstall.register(Package('xmltodict', Version(0, 12, 0)))
from webkitscmpy.contributor import Contributor
from webkitscmpy.commit import Commit
Modified: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/__init__.py (270037 => 270038)
--- trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/__init__.py 2020-11-19 17:37:49 UTC (rev 270037)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/__init__.py 2020-11-19 18:49:36 UTC (rev 270038)
@@ -1,3 +1,4 @@
# Copyright (C) 2020 Apple Inc. All rights reserved.
from webkitscmpy.mocks import local
+from webkitscmpy.mocks import remote
Copied: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/__init__.py (from rev 270037, trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/__init__.py) (0 => 270038)
--- trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/__init__.py (rev 0)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/__init__.py 2020-11-19 18:49:36 UTC (rev 270038)
@@ -0,0 +1,23 @@
+# Copyright (C) 2020 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:
+# 1. Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# 2. 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.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS 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 APPLE INC. OR ITS 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 webkitscmpy.mocks.remote.svn import Svn
Added: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/svn.py (0 => 270038)
--- trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/svn.py (rev 0)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/mocks/remote/svn.py 2020-11-19 18:49:36 UTC (rev 270038)
@@ -0,0 +1,381 @@
+# Copyright (C) 2020 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:
+# 1. Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# 2. 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.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS 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 APPLE INC. OR ITS 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
+import re
+import xmltodict
+
+from collections import OrderedDict
+from datetime import datetime
+from webkitcorepy import mocks
+from webkitscmpy import Commit, Contributor, remote as scmremote
+
+
+class Svn(mocks.Requests):
+ top = None
+ REVISION_REQUEST_RE = re.compile(r'svn/rvr/(?P<revision>\d+)(/(?P<category>\S+))?$')
+
+ def __init__(self, remote='svn.webkit.org/repository/webkit'):
+ if not scmremote.Svn.is_webserver('https://{}'.format(remote)):
+ raise ValueError('"{}" is not a valid Svn remote'.format(remote))
+
+ super(Svn, self).__init__(remote.split('/')[0])
+ if remote[-1] != '/':
+ remote += '/'
+ self.remote = remote
+ self._cache_contents = None
+ self.patches.append(scmremote.Svn('http://{}'.format(self.remote))._cache_lock())
+
+ # Provide a reasonable set of commits to test against
+ contributor = Contributor(name='Jonathan Bedard', emails=['jbed...@apple.com'])
+ self.commits = {
+ 'trunk': [
+ Commit(
+ identifier='1@trunk',
+ revision=1,
+ author=contributor,
+ timestamp=1601660100,
+ message='1st commit\n',
+ ), Commit(
+ identifier='2@trunk',
+ revision=2,
+ author=contributor,
+ timestamp=1601661100,
+ message='2nd commit\n',
+ ), Commit(
+ identifier='3@trunk',
+ revision=4,
+ author=contributor,
+ timestamp=1601663100,
+ message='4th commit\n',
+ ), Commit(
+ identifier='4@trunk',
+ revision=6,
+ author=contributor,
+ timestamp=1601665100,
+ message='6th commit\n',
+ ),
+ ], 'branch-a': [
+ Commit(
+ identifier='2.1@branch-a',
+ revision=3,
+ author=contributor,
+ timestamp=1601662100,
+ message='3rd commit\n',
+ ), Commit(
+ identifier='2.2@branch-a',
+ revision=7,
+ author=contributor,
+ timestamp=1601666100,
+ message='7th commit\n',
+ ),
+ ],
+ }
+ self.commits['branch-b'] = [
+ self.commits['branch-a'][0], Commit(
+ identifier='2.2@branch-b',
+ revision=5,
+ author=contributor,
+ timestamp=1601664100,
+ message='5th commit\n',
+ ), Commit(
+ identifier='2.3@branch-b',
+ revision=8,
+ author=contributor,
+ timestamp=1601667100,
+ message='8th commit\n',
+ ),
+ ]
+
+ self.commits['tags/tag-1'] = [
+ self.commits['branch-a'][0],
+ self.commits['branch-a'][1], Commit(
+ identifier='2.3@tags/tag-1',
+ revision=9,
+ author=contributor,
+ timestamp=1601668100,
+ message='9th commit\n',
+ ),
+ ]
+ self.commits['tags/tag-2'] = [
+ self.commits['branch-b'][0],
+ self.commits['branch-b'][1],
+ self.commits['branch-b'][2], Commit(
+ identifier='2.4@tags/tag-2',
+ revision=10,
+ author=contributor,
+ timestamp=1601669100,
+ message='10th commit\n',
+ ),
+ ]
+
+ def __enter__(self):
+ super(Svn, self).__enter__()
+
+ cache_path = scmremote.Svn('http://{}'.format(self.remote))._cache_path
+ if os.path.isfile(cache_path):
+ with open(cache_path, 'r') as cache:
+ self._cache_contents = cache.read()
+ os.remove(cache_path)
+
+ return self
+
+ def __exit__(self, *args, **kwargs):
+ cache_path = scmremote.Svn('http://{}'.format(self.remote))._cache_path
+ if os.path.isfile(cache_path):
+ os.remove(cache_path)
+ if self._cache_contents:
+ with open(cache_path, 'w') as cache:
+ cache.write(self._cache_contents)
+
+ super(Svn, self).__exit__(*args, **kwargs)
+
+ def latest(self):
+ latest = self.commits['trunk'][-1]
+ for branch in self.commits.values():
+ for commit in branch:
+ if commit.revision > latest.revision:
+ latest = commit
+ return latest
+
+ def branches(self, revision=None):
+ revision = revision or self.latest()
+ branches = set()
+ for branch, commits in self.commits.items():
+ for commit in commits:
+ if commit.revision <= revision and not commit.branch.startswith('tags') and commit.branch != 'trunk':
+ branches.add(commit.branch)
+ return sorted(branches)
+
+ def tags(self, revision=None):
+ revision = revision or self.latest()
+ tags = set()
+ for branch, commits in self.commits.items():
+ for commit in commits:
+ if commit.revision <= revision and commit.branch.startswith('tags'):
+ tags.add(commit.branch)
+ return sorted(tags)
+
+ def range(self, category=None, start=None, end=None):
+ start = start or self.latest()
+ end = end or 1
+
+ if category and category.startswith('branches/'):
+ category = category.split('/')[-1]
+
+ if not category:
+ for commits in self.commits.values():
+ for commit in commits:
+ if commit.revision == start:
+ category = commit.branch
+ break
+
+ if not category:
+ return []
+
+ result = [commit for commit in reversed(self.commits[category])]
+ if self.commits[category][0].branch_point:
+ result += [commit for commit in reversed(self.commits['trunk'][:self.commits[category][0].branch_point])]
+
+ for index in reversed(range(len(result))):
+ if result[index].revision < end:
+ result = result[:index]
+ continue
+ if result[index].revision > start:
+ result = result[index:]
+ break
+
+ return result
+
+ def request(self, method, url, data="" **kwargs):
+ if not url.startswith('http://') and not url.startswith('https://'):
+ return mocks.Response.create404(url)
+
+ data = "" if data else None
+ stripped_url = url.split('://')[-1]
+
+ # Latest revision
+ if method == 'OPTIONS' and stripped_url == self.remote and data == OrderedDict((
+ ('D:options', OrderedDict((
+ ('@xmlns:D', 'DAV:'),
+ ('D:activity-collection-set', None),
+ ))),
+ )):
+ return mocks.Response.fromText(
+ url=""
+ data=''
+ '<D:options-response xmlns:D="DAV:">\n'
+ '<D:activity-collection-set><D:href>/repository/webkit/!svn/act/</D:href></D:activity-collection-set></D:options-response>\n',
+ headers={
+ 'SVN-Youngest-Rev': str(self.latest().revision),
+ }
+ )
+
+ # List category
+ match = self.REVISION_REQUEST_RE.match(stripped_url.split('!')[-1])
+ if method == 'PROPFIND' and stripped_url.startswith('{}!'.format(self.remote)) and match and data == OrderedDict((
+ ('propfind', OrderedDict((
+ ('@xmlns', 'DAV:'),
+ ('prop', OrderedDict((
+ ('resourcetype', OrderedDict((
+ ('@xmlns', 'DAV:'),
+ ))),
+ ))),
+ ))),
+ )):
+ links = [stripped_url[len(stripped_url.split('/')[0]):]]
+ if links[0][-1] != '/':
+ links[0] += '/'
+
+ if match.group('category') == 'branches':
+ for branch in self.branches(int(match.group('revision'))):
+ links.append('{}{}/'.format(links[0], branch))
+
+ elif match.group('category') == 'tags':
+ for tag in self.tags(int(match.group('revision'))):
+ links.append('{}{}/'.format(links[0][:-5], tag))
+
+ else:
+ return mocks.Response.create404(url)
+
+ return mocks.Response(
+ status_code=207,
+ url=""
+ text='<?xml version="1.0" encoding="utf-8"?>\n'
+ '<D:multistatus xmlns:D="DAV:" xmlns:ns0="DAV:">\n'
+ '{}</D:multistatus>\n'.format(
+ ''.join([
+ '<D:response xmlns:lp1="DAV:">\n'
+ '<D:href>{}</D:href>\n'
+ '<D:propstat>\n'
+ '<D:prop>\n'
+ '<lp1:resourcetype><D:collection/></lp1:resourcetype>\n'
+ '</D:prop>\n'
+ '<D:status>HTTP/1.1 200 OK</D:status>\n'
+ '</D:propstat>\n'
+ '</D:response>\n'.format(link) for link in links
+ ])),
+ )
+
+ # Info for commit, branch or tag
+ if method == 'PROPFIND' and stripped_url.startswith('{}!'.format(self.remote)) and match and data == OrderedDict((
+ ('propfind', OrderedDict((
+ ('@xmlns', 'DAV:'),
+ ('prop', OrderedDict((
+ ('resourcetype', OrderedDict((('@xmlns', 'DAV:'),))),
+ ('getcontentlength', OrderedDict((('@xmlns', 'DAV:'),))),
+ ('deadprop-count', OrderedDict((('@xmlns', 'http://subversion.tigris.org/xmlns/dav/'),))),
+ ('version-name', OrderedDict((('@xmlns', 'DAV:'),))),
+ ('creationdate', OrderedDict((('@xmlns', 'DAV:'),))),
+ ('creator-displayname', OrderedDict((('@xmlns', 'DAV:'),))),
+ ))),
+ ))),
+ )):
+ branch = match.group('category')
+ if branch.startswith('branches'):
+ branch = '/'.join(branch.split('/')[1:])
+
+ if branch not in self.commits:
+ return mocks.Response.create404(url)
+ commit = self.commits[branch][0]
+ for candidate in self.commits[branch]:
+ if candidate.revision <= int(match.group('revision')):
+ commit = candidate
+
+ stripped_url = stripped_url[len(stripped_url.split('/')[0]):]
+ if stripped_url[-1] != '/':
+ stripped_url += '/'
+
+ return mocks.Response(
+ status_code=207,
+ url=""
+ text='<?xml version="1.0" encoding="utf-8"?>\n'
+ '<D:multistatus xmlns:D="DAV:" xmlns:ns0="DAV:">\n'
+ '<D:response xmlns:lp1="DAV:" xmlns:lp3="http://subversion.tigris.org/xmlns/dav/" xmlns:g0="DAV:">\n'
+ '<D:href>{}</D:href>\n'
+ '<D:propstat>\n'
+ '<D:prop>\n'
+ '<lp1:resourcetype><D:collection/></lp1:resourcetype>\n'
+ '<lp3:deadprop-count>1</lp3:deadprop-count>\n'
+ '<lp1:version-name>{}</lp1:version-name>\n'
+ '<lp1:creationdate>{}</lp1:creationdate>\n'
+ '<lp1:creator-displayname>{}</lp1:creator-displayname>\n'
+ '</D:prop>\n'
+ '<D:status>HTTP/1.1 200 OK</D:status>\n'
+ '</D:propstat>\n'
+ '<D:propstat>\n'
+ '<D:prop>\n'
+ '<g0:getcontentlength/>\n'
+ '</D:prop>\n'
+ '<D:status>HTTP/1.1 404 Not Found</D:status>\n'
+ '</D:propstat>\n'
+ '</D:response>\n'
+ '</D:multistatus>\n'.format(
+ stripped_url,
+ commit.revision,
+ datetime.fromtimestamp(commit.timestamp).strftime('%Y-%m-%dT%H:%M:%S.103754Z'),
+ commit.author.email,
+ ),
+ )
+
+ # Log for commit
+ if method == 'REPORT' and stripped_url.startswith('{}!'.format(self.remote)) and match and data.get('S:log-report'):
+ commits = self.range(
+ category=match.group('category'),
+ start=int(data['S:log-report']['S:start-revision']),
+ end=int(data['S:log-report']['S:end-revision']),
+ )
+
+ limit = int(data['S:log-report'].get('S:limit', 0))
+ if limit and len(commits) > limit:
+ commits = commits[:limit]
+
+ if not commits:
+ return mocks.Response.create404(url)
+
+ return mocks.Response(
+ status_code=200,
+ url=""
+ text='<?xml version="1.0" encoding="utf-8"?>\n'
+ '<S:log-report xmlns:S="svn:" xmlns:D="DAV:">\n'
+ '{}</S:log-report>\n'.format(
+ ''.join([
+ '<S:log-item>\n'
+ '<D:version-name>{}</D:version-name>\n'
+ '<S:date>{}</S:date>\n'
+ '{}{}</S:log-item>\n'.format(
+ commit.revision,
+ datetime.fromtimestamp(commit.timestamp).strftime('%Y-%m-%dT%H:%M:%S.103754Z'),
+ '' if data['S:log-report'].get('S:revpro') else '<D:comment>{}</D:comment>\n'
+ '<D:creator-displayname>{}</D:creator-displayname>\n'.format(
+ commit.message,
+ commit.author.email,
+ ),
+ '<S:modified-path node-kind="file" text-mods="true" prop-mods="false">/{branch}/Changelog</S:modified-path>\n'
+ '<S:modified-path node-kind="file" text-mods="true" prop-mods="false">/{branch}/file.cpp</S:modified-path>\n'.format(
+ branch=commit.branch if commit.branch.split('/')[0] in ['trunk', 'tags'] else 'branches/{}'.format(commit.branch),
+ ) if 'S:discover-changed-paths' in data['S:log-report'] else '',
+ ) for commit in commits
+ ])),
+ )
+
+ return mocks.Response.create404(url)
Copied: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/__init__.py (from rev 270037, trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/__init__.py) (0 => 270038)
--- trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/__init__.py (rev 0)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/__init__.py 2020-11-19 18:49:36 UTC (rev 270038)
@@ -0,0 +1,24 @@
+# Copyright (C) 2020 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:
+# 1. Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# 2. 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.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS 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 APPLE INC. OR ITS 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 webkitscmpy.remote.scm import Scm
+from webkitscmpy.remote.svn import Svn
Copied: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/scm.py (from rev 270037, trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/__init__.py) (0 => 270038)
--- trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/scm.py (rev 0)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/scm.py 2020-11-19 18:49:36 UTC (rev 270038)
@@ -0,0 +1,42 @@
+# Copyright (C) 2020 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:
+# 1. Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# 2. 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.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS 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 APPLE INC. OR ITS 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 six
+
+from webkitscmpy.scm_base import ScmBase
+
+
+class Scm(ScmBase):
+ @classmethod
+ def from_url(cls, url):
+ from webkitscmpy import remote
+
+ if remote.Svn.is_webserver(url):
+ return remote.Svn(url)
+ raise OSError("'{}' is not a known SCM server".format(url))
+
+ def __init__(self, url, dev_branches=None, prod_branches=None):
+ super(Scm, self).__init__(dev_branches=dev_branches, prod_branches=prod_branches)
+
+ if not isinstance(url, six.string_types):
+ raise ValueError("Expected 'url' to be a string type, not '{}'".format(type(url)))
+ self.url = ""
Added: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/svn.py (0 => 270038)
--- trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/svn.py (rev 0)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/remote/svn.py 2020-11-19 18:49:36 UTC (rev 270038)
@@ -0,0 +1,446 @@
+# Copyright (C) 2020 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:
+# 1. Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# 2. 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.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS 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 APPLE INC. OR ITS 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 bisect
+import calendar
+import fasteners
+import json
+import os
+import re
+import requests
+import tempfile
+import xmltodict
+
+from datetime import datetime
+
+from webkitcorepy import log, run, decorators
+from webkitscmpy.remote.scm import Scm
+from webkitscmpy import Commit, Contributor, Version
+
+
+class Svn(Scm):
+ URL_RE = re.compile(r'\Ahttps?://svn.(?P<host>\S+)/repository/\S+\Z')
+ HISTORY_RE = re.compile(b'<D:version-name>(?P<revision>\d+)</D:version-name>')
+ CACHE_VERSION = Version(1)
+
+ @classmethod
+ def is_webserver(cls, url):
+ return True if cls.URL_RE.match(url) else False
+
+ def __init__(self, url, dev_branches=None, prod_branches=None):
+ if url[-1] != '/':
+ url += '/'
+ if not self.is_webserver(url):
+ raise self.Exception("'{}' is not a valid SVN webserver".format(url))
+ super(Svn, self).__init__(url, dev_branches=dev_branches, prod_branches=prod_branches)
+
+ if os.path.exists(self._cache_path):
+ try:
+ with self._cache_lock(), open(self._cache_path) as file:
+ self._metadata_cache = json.load(file)
+ except BaseException:
+ self._metadata_cache = dict(version=str(self.CACHE_VERSION))
+ else:
+ self._metadata_cache = dict(version=str(self.CACHE_VERSION))
+
+ @property
+ def is_svn(self):
+ return True
+
+ @decorators.Memoize(timeout=60)
+ def _latest(self):
+ response = requests.request(
+ method='OPTIONS',
+ url=""
+ headers={
+ 'Content-Type': 'text/xml',
+ 'Accept-Encoding': 'gzip',
+ 'DEPTH': '1',
+ }, data=''
+ '<D:options xmlns:D="DAV:">\n'
+ ' <D:activity-collection-set></D:activity-collection-set>\n'
+ '</D:options>\n',
+ )
+ if response.status_code != 200:
+ return None
+ return int(response.headers.get('SVN-Youngest-Rev'))
+
+ @decorators.Memoize(cached=False)
+ def info(self, branch=None, revision=None, tag=None):
+ if tag and branch:
+ raise ValueError('Cannot specify both branch and tag')
+ if tag and revision:
+ raise ValueError('Cannot specify both branch and tag')
+
+ if not revision:
+ branch = branch or self.default_branch
+ revision = self._latest()
+ if not revision:
+ return None
+
+ if not revision:
+ raise ValueError('Failed to find the latest revision')
+
+ url = ''.format(self.url, revision)
+ if branch and branch != self.default_branch and '/' not in branch:
+ url = ''.format(url, branch)
+ elif tag:
+ url = ''.format(url, tag)
+ elif branch:
+ url = ''.format(url, branch or self.default_branch)
+
+ response = requests.request(
+ method='PROPFIND',
+ url=""
+ headers={
+ 'Content-Type': 'text/xml',
+ 'Accept-Encoding': 'gzip',
+ 'DEPTH': '1',
+ }, data=''
+ ' <prop>\n'
+ ' <resourcetype xmlns="DAV:"/>\n'
+ ' <getcontentlength xmlns="DAV:"/>\n'
+ ' <deadprop-count xmlns="http://subversion.tigris.org/xmlns/dav/"/>\n'
+ ' <version-name xmlns="DAV:"/>\n'
+ ' <creationdate xmlns="DAV:"/>\n'
+ ' <creator-displayname xmlns="DAV:"/>\n'
+ ' </prop>\n'
+ '</propfind>\n',
+ )
+ if response.status_code not in [200, 207]:
+ return {}
+
+ response = xmltodict.parse(response.text)
+ response = response.get('D:multistatus', response).get('D:response', [])
+ if not response:
+ return {}
+
+ response = response[0] if isinstance(response, list) else response
+ response = response['D:propstat'][0]['D:prop']
+
+ return {
+ 'Last Changed Rev': response['lp1:version-name'],
+ 'Last Changed Author': response['lp1:creator-displayname'],
+ 'Last Changed Date': ' '.join(response['lp1:creationdate'].split('T')).split('.')[0],
+ 'Revision': revision,
+ }
+
+ @property
+ def default_branch(self):
+ return 'trunk'
+
+ def list(self, category):
+ revision = self._latest()
+ if not revision:
+ return []
+
+ response = requests.request(
+ method='PROPFIND',
+ url=''.format(self.url, revision, category),
+ headers={
+ 'Content-Type': 'text/xml',
+ 'Accept-Encoding': 'gzip',
+ 'DEPTH': '1',
+ }, data=''
+ '<propfind xmlns="DAV:">\n'
+ ' <prop><resourcetype xmlns="DAV:"/></prop>\n'
+ '</propfind>\n',
+ )
+ if response.status_code not in [200, 207]:
+ return []
+
+ responses = xmltodict.parse(response.text)
+ responses = responses.get('D:multistatus', responses).get('D:response', [])
+
+ results = []
+ for response in responses:
+ candidate = response['D:href'].split('!svn/rvr/{}/{}/'.format(revision, category))[-1].rstrip('/')
+ if not candidate:
+ continue
+ results.append(candidate)
+
+ return results
+
+ @property
+ def branches(self):
+ return [self.default_branch] + self.list('branches')
+
+ @property
+ def tags(self):
+ return self.list('tags')
+
+ @property
+ @decorators.Memoize()
+ def _cache_path(self):
+ return os.path.join(tempfile.gettempdir(), 'svn.{}'.format(self.URL_RE.match(self.url).group('host')), 'webkitscmpy-cache.json')
+
+ def _cache_lock(self):
+ return fasteners.InterProcessLock(os.path.join(os.path.dirname(self._cache_path), 'cache.lock'))
+
+ def _cache_revisions(self, branch=None):
+ branch = branch or self.default_branch
+ is_default_branch = branch == self.default_branch
+ if branch not in self._metadata_cache:
+ self._metadata_cache[branch] = [0] if is_default_branch else []
+ pos = len(self._metadata_cache[branch])
+
+ # If we aren't on the default branch, we will need the default branch to determine when
+ # our branch intersects with the default branch.
+ if not is_default_branch:
+ self._cache_revisions(branch=self.default_branch)
+
+ did_warn = False
+ count = 0
+
+ latest = self._latest()
+ with requests.request(
+ method='REPORT',
+ url=''.format(
+ self.url,
+ latest,
+ branch if is_default_branch or '/' in branch else 'branches/{}'.format(branch),
+ ), stream=True,
+ headers={
+ 'Content-Type': 'text/xml',
+ 'Accept-Encoding': 'gzip',
+ 'DEPTH': '1',
+ }, data=''
+ '<S:start-revision>{revision}</S:start-revision>\n'
+ '<S:end-revision>0</S:end-revision>\n'
+ '<S:revprop>svn:date</S:revprop>\n'
+ '</S:log-report>\n'.format(revision=latest),
+ ) as response:
+ if response.status_code != 200:
+ raise self.Exception("Failed to construct branch history for '{}'".format(branch))
+
+ for line in response.iter_lines():
+ match = self.HISTORY_RE.match(line)
+ if not match:
+ continue
+
+ if not did_warn:
+ count += 1
+ if count > 1000:
+ self.log('Caching commit data for {}, this will take a few minutes...'.format(branch))
+ did_warn = True
+
+ revision = int(match.group('revision'))
+ if pos > 0 and self._metadata_cache[branch][pos - 1] == revision:
+ break
+ if not is_default_branch:
+ if revision in self._metadata_cache[self.default_branch]:
+ self._metadata_cache[branch].insert(pos, revision)
+ break
+ self._metadata_cache[branch].insert(pos, revision)
+
+ if self._metadata_cache[self.default_branch][0] == [0]:
+ self._metadata_cache['identifier'] = len(self._metadata_cache[branch])
+
+ try:
+ if not os.path.isdir(os.path.dirname(self._cache_path)):
+ os.makedirs(os.path.dirname(self._cache_path))
+ with self._cache_lock(), open(self._cache_path, 'w') as file:
+ json.dump(self._metadata_cache, file, indent=4)
+ except (IOError, OSError):
+ self.log("Failed to write SVN cache to '{}'".format(self._cache_path))
+
+ return self._metadata_cache[branch]
+
+ def _branch_for(self, revision):
+ response = requests.request(
+ method='REPORT',
+ url=''.format(self.url, revision),
+ headers={
+ 'Content-Type': 'text/xml',
+ 'Accept-Encoding': 'gzip',
+ 'DEPTH': '1',
+ }, data=''
+ '<S:start-revision>{revision}</S:start-revision>\n'
+ '<S:end-revision>{revision}</S:end-revision>\n'
+ '<S:limit>1</S:limit>\n'
+ '<S:discover-changed-paths/>\n'
+ '</S:log-report>\n'.format(revision=revision),
+ )
+
+ # If we didn't get a valid answer from the remote, but we found a matching candidate, we return that.
+ # This is a bit risky because there is a chance the branch we have cached is not the canonical branch
+ # for a revision, but this is pretty unlikely because it would require the n + 1 level branch to be cached
+ # but not the n level branch.
+ if response.status_code != 200:
+ raise self.Exception("Failed to retrieve branch for '{}'".format(revision))
+
+ partial = None
+ items = xmltodict.parse(response.text)['S:log-report']['S:log-item']
+ for group in (items.get('S:modified-path', []), items.get('S:added-path', [])):
+ for item in group if isinstance(group, list) else [group]:
+ if not partial:
+ partial = item['#text']
+ while not item['#text'].startswith(partial):
+ partial = partial[:-1]
+
+ candidate = partial.split('/')[2 if partial.startswith('/branches') else 1]
+
+ # Tags are a unique case for SVN, because they're treated as branches in native SVN
+ if candidate == 'tags':
+ return partial[1:].rstrip('/')
+ return candidate
+
+ def _commit_count(self, revision=None, branch=None):
+ branch = branch or self.default_branch
+
+ if revision:
+ if revision not in self._metadata_cache[branch]:
+ raise self.Exception("Failed to find '{}' on '{}'".format(revision, branch))
+ return bisect.bisect_left(self._metadata_cache[branch], int(revision))
+ if branch == self.default_branch:
+ return len(self._metadata_cache[branch])
+ return self._commit_count(revision=self._metadata_cache[branch][0], branch=self.default_branch)
+
+ def commit(self, hash=None, revision=None, identifier=None, branch=None, tag=None):
+ if hash:
+ raise ValueError('SVN does not support Git hashes')
+
+ parsed_branch_point = None
+ if identifier is not None:
+ if revision:
+ raise ValueError('Cannot define both revision and identifier')
+ if tag:
+ raise ValueError('Cannot define both tag and identifier')
+
+ parsed_branch_point, identifier, parsed_branch = Commit._parse_identifier(identifier, do_assert=True)
+ if parsed_branch:
+ if branch and branch != parsed_branch:
+ raise ValueError(
+ "Caller passed both 'branch' and 'identifier', but specified different branches ({} and {})".format(
+ branch, parsed_branch,
+ ),
+ )
+ branch = parsed_branch
+ branch = branch or self.default_branch
+
+ if branch == self.default_branch and parsed_branch_point:
+ raise self.Exception('Cannot provide a branch point for a commit on the default branch')
+
+ if not self._metadata_cache.get(branch, []) or identifier >= len(self._metadata_cache.get(branch, [])):
+ if branch != self.default_branch:
+ self._cache_revisions(branch=self.default_branch)
+ self._cache_revisions(branch=branch)
+ if identifier > len(self._metadata_cache.get(branch, [])):
+ raise self.Exception('Identifier {} cannot be found on the specified branch in the current checkout'.format(identifier))
+
+ if identifier <= 0:
+ if branch == self.default_branch:
+ raise self.Exception('Illegal negative identifier on the default branch')
+ identifier = self._commit_count(branch=branch) + identifier
+ if identifier < 0:
+ raise self.Exception('Identifier does not exist on the specified branch')
+
+ branch = self.default_branch
+
+ revision = self._metadata_cache[branch][identifier]
+ info = self.info(cached=True, branch=branch, revision=revision)
+ branch = self._branch_for(revision)
+ if not self._metadata_cache.get(branch, []) or identifier >= len(self._metadata_cache.get(branch, [])):
+ self._cache_revisions(branch=branch)
+
+ elif revision:
+ if branch:
+ raise ValueError('Cannot define both branch and revision')
+ if tag:
+ raise ValueError('Cannot define both tag and revision')
+ revision = Commit._parse_revision(revision, do_assert=True)
+ branch = self._branch_for(revision)
+ info = self.info(cached=True, branch=branch, revision=revision)
+
+ else:
+ if branch and tag:
+ raise ValueError('Cannot define both branch and tag')
+
+ branch = None if tag else branch or self.default_branch
+ info = self.info(tag=tag) if tag else self.info(branch=branch)
+ if not info:
+ raise self.Exception("'{}' is not a recognized {}".format(
+ tag or branch,
+ 'tag' if tag else 'branch',
+ ))
+ revision = int(info['Last Changed Rev'])
+ if branch != self.default_branch:
+ branch = self._branch_for(revision)
+
+ date = datetime.strptime(info['Last Changed Date'], '%Y-%m-%d %H:%M:%S')
+
+ if not identifier:
+ if branch != self.default_branch and revision > self._metadata_cache.get(self.default_branch, [0])[-1]:
+ self._cache_revisions(branch=self.default_branch)
+ if revision not in self._metadata_cache.get(branch, []):
+ self._cache_revisions(branch=branch)
+ identifier = self._commit_count(revision=revision, branch=branch)
+
+ branch_point = None if branch == self.default_branch else self._commit_count(branch=branch)
+ if branch_point and parsed_branch_point and branch_point != parsed_branch_point:
+ raise ValueError("Provided 'branch_point' does not match branch point of specified branch")
+
+ response = requests.request(
+ method='REPORT',
+ url=''.format(self.url, revision),
+ headers={
+ 'Content-Type': 'text/xml',
+ 'Accept-Encoding': 'gzip',
+ 'DEPTH': '1',
+ }, data=''
+ '<S:start-revision>{revision}</S:start-revision>\n'
+ '<S:end-revision>{revision}</S:end-revision>\n'
+ '<S:limit>1</S:limit>\n'
+ '</S:log-report>\n'.format(revision=revision),
+ )
+
+ if response.status_code == 200:
+ response = xmltodict.parse(response.text)
+ response = response.get('S:log-report', {}).get('S:log-item')
+
+ name = response.get('D:creator-displayname')
+ message = response.get('D:comment', None)
+ if not name:
+ raise self.Exception('Failed to find creator name')
+
+ else:
+ self.log('Failed to connect to remote, cannot compute commit message')
+ message = None
+ name = info['Last Changed Author']
+
+ author = Contributor.by_email.get(
+ name,
+ Contributor.by_name.get(
+ name,
+ Contributor(name=name, emails=[name] if '@' in name else []),
+ ),
+ )
+
+ return Commit(
+ revision=int(revision),
+ branch=branch,
+ identifier=identifier,
+ branch_point=branch_point,
+ timestamp=int(calendar.timegm(date.timetuple())),
+ author=author,
+ message=message,
+ )
Modified: trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/test/svn_unittest.py (270037 => 270038)
--- trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/test/svn_unittest.py 2020-11-19 17:37:49 UTC (rev 270037)
+++ trunk/Tools/Scripts/libraries/webkitscmpy/webkitscmpy/test/svn_unittest.py 2020-11-19 18:49:36 UTC (rev 270038)
@@ -27,10 +27,10 @@
from datetime import datetime
from webkitcorepy import OutputCapture
-from webkitscmpy import local, mocks
+from webkitscmpy import local, mocks, remote
-class TestSvn(unittest.TestCase):
+class TestLocalSvn(unittest.TestCase):
path = '/mock/repository'
def test_detection(self):
@@ -217,3 +217,87 @@
self.assertEqual(9, repository.checkout('tag-1').revision)
self.assertEqual(9, repository.commit().revision)
+
+
+class TestRemoteSvn(unittest.TestCase):
+ remote = 'https://svn.webkit.org/repository/webkit'
+
+ def test_detection(self):
+ self.assertEqual(remote.Svn.is_webserver('https://svn.webkit.org/repository/webkit'), True)
+ self.assertEqual(remote.Svn.is_webserver('http://svn.webkit.org/repository/webkit'), True)
+ self.assertEqual(remote.Svn.is_webserver('https://github.com/WebKit/webkit'), False)
+
+ def test_branches(self):
+ with mocks.remote.Svn():
+ self.assertEqual(
+ remote.Svn(self.remote).branches,
+ ['trunk', 'branch-a', 'branch-b'],
+ )
+
+ def test_tags(self):
+ with mocks.remote.Svn():
+ self.assertEqual(
+ remote.Svn(self.remote).tags,
+ ['tag-1', 'tag-2'],
+ )
+
+ def test_scm_type(self):
+ self.assertTrue(remote.Svn(self.remote).is_svn)
+ self.assertFalse(remote.Svn(self.remote).is_git)
+
+ def test_info(self):
+ with mocks.remote.Svn():
+ self.assertDictEqual({
+ 'Last Changed Author': 'jbed...@apple.com',
+ 'Last Changed Date': '2020-10-02 11:58:20',
+ 'Last Changed Rev': '6',
+ 'Revision': 10,
+ }, remote.Svn(self.remote).info())
+
+ def test_commit_revision(self):
+ with mocks.remote.Svn():
+ self.assertEqual('1@trunk', str(remote.Svn(self.remote).commit(revision=1)))
+ self.assertEqual('2@trunk', str(remote.Svn(self.remote).commit(revision=2)))
+ self.assertEqual('2.1@branch-a', str(remote.Svn(self.remote).commit(revision=3)))
+ self.assertEqual('3@trunk', str(remote.Svn(self.remote).commit(revision=4)))
+ self.assertEqual('2.2@branch-b', str(remote.Svn(self.remote).commit(revision=5)))
+ self.assertEqual('4@trunk', str(remote.Svn(self.remote).commit(revision=6)))
+ self.assertEqual('2.2@branch-a', str(remote.Svn(self.remote).commit(revision=7)))
+ self.assertEqual('2.3@branch-b', str(remote.Svn(self.remote).commit(revision=8)))
+
+ # Out-of-bounds commit
+ with self.assertRaises(remote.Svn.Exception):
+ self.assertEqual(None, remote.Svn(self.remote).commit(revision=11))
+
+ def test_commit_from_branch(self):
+ with mocks.remote.Svn():
+ self.assertEqual('4@trunk', str(remote.Svn(self.remote).commit(branch='trunk')))
+ self.assertEqual('2.2@branch-a', str(remote.Svn(self.remote).commit(branch='branch-a')))
+ self.assertEqual('2.3@branch-b', str(remote.Svn(self.remote).commit(branch='branch-b')))
+
+ def test_identifier(self):
+ with mocks.remote.Svn():
+ self.assertEqual(1, remote.Svn(self.remote).commit(identifier='1@trunk').revision)
+ self.assertEqual(2, remote.Svn(self.remote).commit(identifier='2@trunk').revision)
+ self.assertEqual(3, remote.Svn(self.remote).commit(identifier='2.1@branch-a').revision)
+ self.assertEqual(4, remote.Svn(self.remote).commit(identifier='3@trunk').revision)
+ self.assertEqual(5, remote.Svn(self.remote).commit(identifier='2.2@branch-b').revision)
+ self.assertEqual(6, remote.Svn(self.remote).commit(identifier='4@trunk').revision)
+ self.assertEqual(7, remote.Svn(self.remote).commit(identifier='2.2@branch-a').revision)
+ self.assertEqual(8, remote.Svn(self.remote).commit(identifier='2.3@branch-b').revision)
+
+ def test_non_cannonical_identifiers(self):
+ with mocks.remote.Svn():
+ self.assertEqual('2@trunk', str(remote.Svn(self.remote).commit(identifier='0@branch-a')))
+ self.assertEqual('1@trunk', str(remote.Svn(self.remote).commit(identifier='-1@branch-a')))
+
+ self.assertEqual('2@trunk', str(remote.Svn(self.remote).commit(identifier='0@branch-b')))
+ self.assertEqual('1@trunk', str(remote.Svn(self.remote).commit(identifier='-1@branch-b')))
+
+ def test_tag(self):
+ with mocks.remote.Svn():
+ self.assertEqual(9, remote.Svn(self.remote).commit(tag='tag-1').revision)
+
+ def test_tag_previous(self):
+ with mocks.remote.Svn():
+ self.assertEqual(7, remote.Svn(self.remote).commit(identifier='2.2@tags/tag-1').revision)