Index: trac/versioncontrol/tests/__init__.py
===================================================================
--- trac/versioncontrol/tests/__init__.py	(revision 9482)
+++ trac/versioncontrol/tests/__init__.py	(working copy)
@@ -1,6 +1,7 @@
 import unittest
 
-from trac.versioncontrol.tests import cache, diff, svn_authz, svn_fs, api
+from trac.versioncontrol.tests import cache, diff, svn_authz,   \
+                                      svn_fs, svn_prop, api
 from trac.versioncontrol.tests.functional import functionalSuite
 
 def suite():
@@ -10,6 +11,7 @@
     suite.addTest(diff.suite())
     suite.addTest(svn_authz.suite())
     suite.addTest(svn_fs.suite())
+    suite.addTest(svn_prop.suite())
     suite.addTest(api.suite())
     return suite
 
Index: trac/versioncontrol/tests/svn_prop.py
===================================================================
--- trac/versioncontrol/tests/svn_prop.py	(revision 0)
+++ trac/versioncontrol/tests/svn_prop.py	(revision 0)
@@ -0,0 +1,589 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C)2005-2010 Edgewall Software
+# Copyright (C) 2010 Itamar Ostricher <itamarost@gmail.com>
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.org/wiki/TracLicense.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://trac.edgewall.org/log/.
+#
+# Author: Itamar Ostricher <itamarost@gmail.com>
+
+import unittest
+import random
+from urllib import splittype
+
+from trac.test import Mock
+from trac.core import TracError
+from trac.versioncontrol import svn_prop
+
+class SvnExternalsParserTests(unittest.TestCase):
+
+    def test_no_externals(self):
+        externals_prop = u''
+        externals_list = svn_prop.parse_externals(externals_prop,
+                    reponame=u'myrep', repopath=u'dev/trunk/src/my-module')
+        self.assertTrue(isinstance(externals_list, list))
+        self.assertEqual(0, len(externals_list))
+
+    def test_old_syntax_parsing(self):
+        externals_prop = u"""
+            simple http://example.org/svn/repos/dir1
+            # this is a comment that should be ignored.
+            spaceless_rev -r10 svn://example.org/svn/repos/dir2
+            spaced_rev -r 12 svn+ssh://example.org/svn/repos/dir3
+            """
+        externals_list = svn_prop.parse_externals(externals_prop,
+                    reponame=u'myrep', repopath=u'dev/trunk/src/my-module')
+        self.assertTrue(isinstance(externals_list, list))
+        self.assertEqual(3, len(externals_list))
+        for ext_dict in externals_list:
+            self.assertTrue(isinstance(ext_dict, dict))
+            self.assertEqual(ext_dict['reponame'], u'myrep')
+            self.assertEqual(ext_dict['repopath'],
+                             u'dev/trunk/src/my-module')
+        simple, spaceless, spaced = externals_list
+        self.assertEqual(simple['dir'], u'simple')
+        self.assertEqual(simple['url'],
+                         u'http://example.org/svn/repos/dir1')
+        self.assertTrue(simple['rev'] is None)
+        self.assertEqual(spaceless['dir'], u'spaceless_rev')
+        self.assertEqual(spaceless['url'],
+                         u'svn://example.org/svn/repos/dir2')
+        self.assertEqual(10, spaceless['rev'])
+        self.assertEqual(spaced['dir'], u'spaced_rev')
+        self.assertEqual(spaced['url'],
+                         u'svn+ssh://example.org/svn/repos/dir3')
+        self.assertEqual(12, spaced['rev'])
+
+    def test_new_syntax_parsing(self):
+        externals_prop = u"""
+            https://example.org/svn/repos/dir1 simple
+            -r10 ^/repos/dir2 spaceless_rev
+            -r 12 //example.org/svn/repos/dir3 spaced_rev
+            # this is a comment that should be ignored.
+            ../dir4@17 peg_rev
+            """
+        externals_list = svn_prop.parse_externals(externals_prop,
+                    reponame=u'myrep', repopath=u'dev/trunk/src/my-module')
+        self.assertTrue(isinstance(externals_list, list))
+        self.assertEqual(4, len(externals_list))
+        for ext_dict in externals_list:
+            self.assertTrue(isinstance(ext_dict, dict))
+            self.assertEqual(ext_dict['reponame'], u'myrep')
+            self.assertEqual(ext_dict['repopath'],
+                             u'dev/trunk/src/my-module')
+        simple, spaceless, spaced, pegged = externals_list
+        self.assertEqual(simple['dir'], u'simple')
+        self.assertEqual(simple['url'],
+                         u'https://example.org/svn/repos/dir1')
+        self.assertTrue(simple['rev'] is None)
+        self.assertEqual(spaceless['dir'], u'spaceless_rev')
+        self.assertEqual(spaceless['url'],
+                         u'^/repos/dir2')
+        self.assertEqual(10, spaceless['rev'])
+        self.assertEqual(spaced['dir'], u'spaced_rev')
+        self.assertEqual(spaced['url'],
+                         u'//example.org/svn/repos/dir3')
+        self.assertEqual(12, spaced['rev'])
+        self.assertEqual(pegged['dir'], u'peg_rev')
+        self.assertEqual(pegged['url'],
+                         u'../dir4')
+        self.assertEqual(17, pegged['rev'])
+
+# Repositories filesystem layout for following tests:
+# /var/svn
+# |
+# |-repos (svn root of repository "repos", AKA "myrep")
+# | |
+# | |-dev
+# | |
+# | \-devel
+# |   |-common
+# |   | \-trunk
+# |   |  \-log
+# |   \-my-prod
+# |    \-trunk
+# |     \-src
+# |      \-my-module
+# |
+# \-repos-2 (svn root of repository "repos-2", AKA "otherrep")
+#  \-dev
+
+class SvnExternalsRepositoryRelativeLinkTests(unittest.TestCase):
+
+    def setUp(self):
+        self.repo_base = u'svn:random-uuid:/var/svn/repos'
+        self.other_repo_base = u'svn:random-uuid:/var/svn/repos-2'
+        self.repositories = {
+            u'myrep': Mock(reponame=u'myrep', scope=u'/',
+                           get_base=lambda: self.repo_base,
+                           get_path_url=lambda path, rev: None),
+            u'my-devel-rep': Mock(reponame=u'my-devel-rep', scope=u'/devel/',
+                                   get_base=lambda: self.repo_base,
+                                   get_path_url=lambda path, rev: None),
+            u'my-prod-rep': Mock(reponame=u'my-prod-rep',
+                                 scope=u'/devel/my-prod/',
+                                 get_base=lambda: self.repo_base,
+                                 get_path_url=lambda path, rev: None),
+            # Note 'my-dev-rep' purposefully has a scope that prefixes
+            # 'my-devel-rep'. This tests cases of false matching scopes.
+            u'my-dev-rep': Mock(reponame=u'my-dev-rep', scope=u'/dev/',
+                                 get_base=lambda: self.repo_base,
+                                 get_path_url=lambda path, rev: None),
+            u'otherrep': Mock(reponame=u'otherrep', scope=u'/',
+                              get_base=lambda: self.other_repo_base,
+                              get_path_url=lambda path, rev: None),
+            }
+        self.rm = Mock(get_repository=lambda reponame:
+                       self.repositories[reponame])
+
+    def test_repository_relative_url(self):
+        external = {u'reponame': 'myrep',
+                    u'repopath': u'devel/my-prod/trunk/src/my-module',
+                    u'dir': u'local',
+                    u'url': u'^/devel/common/trunk/log',
+                    u'rev': None}
+        scoped_external = {u'reponame': 'my-devel-rep',
+                           # Note the missing 'devel/' in the repo-path
+                           u'repopath': u'my-prod/trunk/src/my-module',
+                           u'dir': u'local',
+                           u'url': u'^/devel/common/trunk/log',
+                           u'rev': None}
+        deep_external = {u'reponame': 'my-devel-rep',
+                         # Note the missing 'devel/my-prod/' in the repo-path
+                         u'repopath': u'trunk/src/my-module',
+                         u'dir': u'local',
+                         u'url': u'^/devel/common/trunk/log',
+                         u'rev': None}
+        
+        for ext in [external, scoped_external, deep_external]:
+            # Un-scoped repository asserts
+            repo = self.rm.get_repository(u'myrep')
+            for scope in [u'', u'/']:
+                repo.scope = scope
+                # External as seen from same repository
+                self.assertEqual(u'myrep/devel/common/trunk/log?rev=%(rev)s',
+                    svn_prop.get_repo_relative_path(ext, u'myrep', rm=self.rm))
+            # Scoped repository asserts
+            repo = self.rm.get_repository(u'my-devel-rep')
+            for scope in [u'devel', u'/devel', u'devel/', u'/devel/']:
+                repo.scope = scope
+                # External as seen from the un-scoped repository
+                # Note the missing '/devel/' in the expected href
+                self.assertEqual(u'my-devel-rep/common/trunk/log?rev=%(rev)s',
+                        svn_prop.get_repo_relative_path(ext, u'my-devel-rep',
+                                                        rm=self.rm))
+            # Deep-scoped repository (too deep for common)
+            self.assertTrue(svn_prop.get_repo_relative_path(ext,
+                                    u'my-prod-rep', rm=self.rm) is None)
+            # Differently-scoped repository (out of range)
+            self.assertTrue(svn_prop.get_repo_relative_path(ext,
+                                        u'my-dev-rep', rm=self.rm) is None)
+            # Another repository, not related to external
+            self.assertTrue(svn_prop.get_repo_relative_path(ext, u'otherrep',
+                                                        rm=self.rm) is None)
+
+    def test_directory_relative_url(self):
+        external = {u'reponame': u'myrep',
+                    u'repopath': u'devel/my-prod/trunk/src/my-module',
+                    u'dir': u'local',
+                    u'url': u'../../../../common/trunk/log',
+                    u'rev': None}
+        scoped_external = {u'reponame': u'my-devel-rep',
+                           # Note the missing 'devel/' in the repo-path
+                           u'repopath': u'my-prod/trunk/src/my-module',
+                           u'dir': u'local',
+                           u'url': u'../../../../common/trunk/log',
+                           u'rev': None}
+        deep_external = {u'reponame': u'my-prod-rep',
+                         # Note the missing 'devel/my-prod/' in the repo-path
+                         u'repopath': u'trunk/src/my-module',
+                         u'dir': u'local',
+                         # Going even deeper just for fun (and test of course)
+                         u'url': u'../../../../../devel/common/trunk/log',
+                         u'rev': None}
+
+        for ext in [external, scoped_external, deep_external]:
+            # Un-scoped repository asserts
+            self.assertEqual(u'myrep/devel/common/trunk/log?rev=%(rev)s',
+                             svn_prop.get_repo_relative_path(ext, u'myrep',
+                                                             rm=self.rm))
+            # Scoped repository asserts
+            self.assertEqual(u'my-devel-rep/common/trunk/log?rev=%(rev)s',
+                             svn_prop.get_repo_relative_path(ext,
+                                                             u'my-devel-rep',
+                                                             rm=self.rm))
+            # Deep-scoped repository asserts (out of reach)
+            self.assertTrue(svn_prop.get_repo_relative_path(ext,
+                                        u'my-prod-rep', rm=self.rm) is None)
+            # Unrelated repository asserts
+            self.assertTrue(svn_prop.get_repo_relative_path(ext,
+                                        u'otherrep', rm=self.rm) is None)
+
+    def test_invalid_directory_relative_url(self):
+        external = {u'reponame': u'myrep',
+                    u'repopath': u'devel/my-prod/trunk/src/my-module',
+                    u'dir': u'local',
+                    # Out of repository base! aaah!
+                    u'url': u'../../../../../../common/trunk/log',
+                    u'rev': None}
+        scoped_external = {u'reponame': u'my-devel-rep',
+                           # Note the missing 'devel/' in the repo-path
+                           u'repopath': u'my-prod/trunk/src/my-module',
+                           u'dir': u'local',
+                           u'url': u'../../../../../../common/trunk/log',
+                           u'rev': None}
+        deep_external = {u'reponame': u'my-prod-rep',
+                        # Note the missing 'devel/my-prod/' in the repo-path
+                        u'repopath': u'trunk/src/my-module',
+                        u'dir': u'local',
+                        # Going even deeper just for fun (and test of course)
+                        u'url': u'../../../../../../../devel/common/trunk/log',
+                        u'rev': None}
+        
+        for ext in [external, scoped_external, deep_external]:
+            self.assertRaises(TracError, svn_prop.get_repo_relative_path,
+                              ext, u'myrep', rm=self.rm)
+            self.assertRaises(TracError, svn_prop.get_repo_relative_path,
+                              ext, u'my-devel-rep', rm=self.rm)
+            self.assertRaises(TracError, svn_prop.get_repo_relative_path,
+                              ext, u'my-prod-rep', rm=self.rm)
+            self.assertTrue(svn_prop.get_repo_relative_path(ext, u'otherrep',
+                                                        rm=self.rm) is None)
+
+class SvnExternalsUrlBasedLinkTests(unittest.TestCase):
+
+    def setUp(self):
+        self.repo_base = u'svn:random-uuid:/var/svn/repos'
+        self.repo_host = u'example.org'
+        self.repo_base_url = u'//%s/svn/myrep' % (self.repo_host)
+        self.other_repo_base = u'svn:random-uuid:/var/svn/repos-2'
+        self.other_repo_base_url = u'//%s/svn/otherrep' % (self.repo_host)
+        self.repositories = {
+            u'myrep': Mock(reponame=u'myrep', scope=u'/',
+                           get_base=lambda: self.repo_base,
+                           get_path_url=lambda path, rev: u'https:%s/%s' %
+                           (self.repo_base_url, path.lstrip(u'/'))),
+            u'my-devel-rep': Mock(reponame=u'my-devel-rep', scope=u'/devel/',
+                                  get_base=lambda: self.repo_base,
+                                  get_path_url=lambda path, rev:
+                                      u'http:%s/devel/%s' %
+                                      (self.repo_base_url, path.lstrip(u'/'))),
+            u'my-prod-rep': Mock(reponame=u'my-prod-rep',
+                                 scope=u'/devel/my-prod/',
+                                 get_base=lambda: self.repo_base,
+                                 get_path_url=lambda path, rev:
+                                      u'svn+ssh:%s/devel/my-prod/%s' %
+                                      (self.repo_base_url, path.lstrip(u'/'))),
+            # Note 'my-dev-rep' purposefully has a scope that prefixes
+            # 'my-devel-rep'. This tests cases of false matching scopes.
+            u'my-dev-rep': Mock(reponame=u'my-dev-rep', scope=u'/dev/',
+                                get_base=lambda: self.repo_base,
+                                get_path_url=lambda path, rev: u'%s/dev/%s' %
+                                      (self.repo_base_url, path.lstrip(u'/'))),
+            # Extra repository, same base as above, without URL definition
+            u'my-nourl-rep': Mock(reponame=u'my-nourl-rep', scope=u'/devel/',
+                                  get_base=lambda: self.repo_base,
+                                  get_path_url=lambda path, rev: None),
+            u'otherrep': Mock(reponame=u'otherrep', scope=u'/',
+                              get_base=lambda: self.other_repo_base,
+                              get_path_url=lambda path, rev: u'svn:%s/%s' %
+                                (self.other_repo_base_url, path.lstrip(u'/'))),
+            }
+        self.rm = Mock(get_repository=lambda reponame:
+                       self.repositories[reponame])
+
+    def test_fully_qualified_and_scheme_relative_url(self):
+        external = {u'reponame': 'myrep',
+                    u'repopath': u'devel/my-prod/trunk/src/my-module',
+                    u'dir': u'local',
+                    u'rev': None}
+        req_scheme = random.choice([u'http', u'https'])
+        req_host = u'local.server'
+        req_href = u'%s://%s/trac/env/browser/myrep/devel/my-prod/'     \
+                   'trunk/src/my-module' % (req_scheme, req_host)
+        use_req_href = random.choice([req_href] * 7 + [None, u'', u'/'])
+
+        ext_url = u'//remote.server.org/third/party/library'
+        for scheme in [u'', u'http:', u'https:', u'svn:', u'svn+ssh:']:
+            external[u'url'] = u'%s%s' % (scheme, ext_url)
+            for reponame in self.repositories.keys():
+                expected_scheme = scheme
+                # Twists:
+                # - If URL is scheme-relative, and repository URL has scheme,
+                #   then take the scheme from the URL.
+                # - If URL is scheme-relative, and repository URL does not
+                #   have scheme, then take the scheme from the request.
+                if not scheme:
+                    repo_url = self.rm.get_repository(reponame)             \
+                               .get_path_url(u'/', None) or u''
+                    if u'://' in repo_url:
+                        # Inherit scheme from Repository URL
+                        expected_scheme = u'%s:' % (splittype(repo_url)[0])
+                    elif use_req_href and u'://' in use_req_href:
+                        # Inherit scheme from request
+                        expected_scheme = u'%s:' % (req_scheme)
+                self.assertEqual(u'%s%s' % (expected_scheme, ext_url),
+                                 svn_prop.get_external_url(external, reponame,
+                                           rm=self.rm, req_href=use_req_href))
+
+    def test_server_relative_url(self):
+        external = {u'reponame': 'myrep',
+                    u'repopath': u'devel/my-prod/trunk/src/my-module',
+                    u'dir': u'local',
+                    # External from myrep points to otherrep via server root!
+                    u'url': u'/svn/otherrep/dev',
+                    u'rev': None}
+        req_scheme = random.choice([u'http', u'https'])
+        req_host = u'local.server'
+        req_href = u'%s://%s/trac/env/browser/myrep/devel/my-prod/'     \
+                   'trunk/src/my-module' % (req_scheme, req_host)
+        use_req_href = random.choice([req_href] * 7 + [None, u'', u'/'])
+        use_req_href = req_href
+        
+        for reponame in self.repositories.keys():
+            repo_url = self.rm.get_repository(reponame)     \
+                       .get_path_url(u'/', None) or u''
+            expected_link = None
+            if repo_url:
+                expected_link = u'//%s%s' % (self.repo_host, external[u'url'])
+                if u'://' in repo_url:
+                    expected_link = u'%s:%s' % (splittype(repo_url)[0],
+                                                expected_link)
+            elif (use_req_href or u'').rstrip(u'/'):
+                expected_link = u'%s://%s%s' % (req_scheme, req_host,
+                                                external[u'url'])
+            if expected_link:
+                self.assertEqual(expected_link,
+                                 svn_prop.get_external_url(external, reponame,
+                                           rm=self.rm, req_href=use_req_href))
+            else:
+                self.assertTrue(svn_prop.get_external_url(external, reponame,
+                                   rm=self.rm, req_href=use_req_href) is None)
+    
+    def test_repository_relative_url(self):
+        external = {u'reponame': 'myrep',
+                    u'repopath': u'devel/my-prod/trunk/src/my-module',
+                    u'dir': u'local',
+                    u'url': u'^/devel/common/trunk/log',
+                    u'rev': None}
+        scoped_external = {u'reponame': 'my-devel-rep',
+                           # Note the missing 'devel/' in the repo-path
+                           u'repopath': u'my-prod/trunk/src/my-module',
+                           u'dir': u'local',
+                           u'url': u'^/devel/common/trunk/log',
+                           u'rev': None}
+        deep_external = {u'reponame': 'my-devel-rep',
+                         # Note the missing 'devel/my-prod/' in the repo-path
+                         u'repopath': u'trunk/src/my-module',
+                         u'dir': u'local',
+                         u'url': u'^/devel/common/trunk/log',
+                         u'rev': None}
+
+        expected_link = u'%s/devel/common/trunk/log' % (self.repo_base_url)
+        for ext in [external, scoped_external, deep_external]:
+            self.assertEqual(u'https:%s' % (expected_link),
+                             svn_prop.get_external_url(ext,
+                                           u'myrep', rm=self.rm))
+            self.assertEqual(u'http:%s' % (expected_link),
+                             svn_prop.get_external_url(ext,
+                                           u'my-devel-rep', rm=self.rm))
+            self.assertEqual(u'svn+ssh:%s' % (expected_link),
+                             svn_prop.get_external_url(ext,
+                                           u'my-prod-rep', rm=self.rm))
+            self.assertEqual(u'%s' % (expected_link),
+                             svn_prop.get_external_url(ext,
+                                           u'my-dev-rep', rm=self.rm))
+            self.assertTrue(svn_prop.get_external_url(ext, u'my-nourl-rep',
+                                                      rm=self.rm) is None)
+            self.assertTrue(svn_prop.get_external_url(ext, u'otherrep',
+                                                      rm=self.rm) is None)
+
+    def test_directory_relative_url(self):
+        external = {u'reponame': u'myrep',
+                    u'repopath': u'devel/my-prod/trunk/src/my-module',
+                    u'dir': u'local',
+                    u'url': u'../../../../common/trunk/log',
+                    u'rev': None}
+        scoped_external = {u'reponame': u'my-devel-rep',
+                           # Note the missing 'devel/' in the repo-path
+                           u'repopath': u'my-prod/trunk/src/my-module',
+                           u'dir': u'local',
+                           u'url': u'../../../../common/trunk/log',
+                           u'rev': None}
+        deep_external = {u'reponame': u'my-prod-rep',
+                         # Note the missing 'devel/my-prod/' in the repo-path
+                         u'repopath': u'trunk/src/my-module',
+                         u'dir': u'local',
+                         # Going even deeper just for fun (and test of course)
+                         u'url': u'../../../../../devel/common/trunk/log',
+                         u'rev': None}
+
+        expected_link = u'%s/devel/common/trunk/log' % (self.repo_base_url)
+        for ext in [external, scoped_external, deep_external]:
+            self.assertEqual(u'https:%s' % (expected_link),
+                             svn_prop.get_external_url(ext,
+                                           u'myrep', rm=self.rm))
+            self.assertEqual(u'http:%s' % (expected_link),
+                             svn_prop.get_external_url(ext,
+                                           u'my-devel-rep', rm=self.rm))
+            self.assertEqual(u'svn+ssh:%s' % (expected_link),
+                             svn_prop.get_external_url(ext,
+                                           u'my-prod-rep', rm=self.rm))
+            self.assertEqual(u'%s' % (expected_link),
+                             svn_prop.get_external_url(ext,
+                                           u'my-dev-rep', rm=self.rm))
+            self.assertTrue(svn_prop.get_external_url(ext, u'my-nourl-rep',
+                                                      rm=self.rm) is None)
+            self.assertTrue(svn_prop.get_external_url(ext, u'otherrep',
+                                                      rm=self.rm) is None)
+
+    def test_invalid_directory_relative_url(self):
+        external = {u'reponame': u'myrep',
+                    u'repopath': u'devel/my-prod/trunk/src/my-module',
+                    u'dir': u'local',
+                    # Out of repository base! aaah!
+                    u'url': u'../../../../../../common/trunk/log',
+                    u'rev': None}
+        scoped_external = {u'reponame': u'my-devel-rep',
+                           # Note the missing 'devel/' in the repo-path
+                           u'repopath': u'my-prod/trunk/src/my-module',
+                           u'dir': u'local',
+                           u'url': u'../../../../../../common/trunk/log',
+                           u'rev': None}
+        deep_external = {u'reponame': u'my-prod-rep',
+                        # Note the missing 'devel/my-prod/' in the repo-path
+                        u'repopath': u'trunk/src/my-module',
+                        u'dir': u'local',
+                        # Going even deeper just for fun (and test of course)
+                        u'url': u'../../../../../../../devel/common/trunk/log',
+                        u'rev': None}
+        
+        for ext in [external, scoped_external, deep_external]:
+            self.assertRaises(TracError, svn_prop.get_external_url,
+                              ext, u'myrep', rm=self.rm)
+            self.assertRaises(TracError, svn_prop.get_external_url,
+                              ext, u'my-devel-rep', rm=self.rm)
+            self.assertRaises(TracError, svn_prop.get_external_url,
+                              ext, u'my-prod-rep', rm=self.rm)
+            self.assertTrue(svn_prop.get_external_url(ext, u'otherrep',
+                                                    rm=self.rm) is None)
+
+class SvnExternalsMapTests(unittest.TestCase):
+
+    def setUp(self):
+        self.ini_section = {
+            '1': u'//server/repos1 '
+            u'http://trac/proj/browser/Rep1/$path?rev=$rev',
+            '2': u'svn://server/repos2/ '
+            u'http://trac/proj/browser/Rep2/$path?rev=$rev',
+            '3': u'http://theirserver.org/svn/eng-soft '
+            u'http://ourserver/viewvc/svn/$path/?pathrev=25914',
+            '4': u'svn://anotherserver.com/tools_repository '
+            u'//ourserver/tracs/tools/browser/$path?rev=$rev',
+            # Testing existing partial match to 1 & 2
+            # and also Trac-env-relative pattern.
+            # Note that need to specify only repository name (no "/browser")
+            '5': u'//server/repo Repo/$path?rev=$rev',
+            # But should work fine also with "/browser" specified
+            # Also testing here for a better (more specific) match than 5
+            '6': u'//server/repo/sub-proj-a '
+            u'/browser/SubProjA/$path?rev=$rev'
+            }
+        self.map_from_ini = {
+            u'//server/repos1':
+                u'http://trac/proj/browser/Rep1/%(path)s?rev=%(rev)s',
+            u'svn://server/repos2':
+                u'http://trac/proj/browser/Rep2/%(path)s?rev=%(rev)s',
+            u'http://theirserver.org/svn/eng-soft':
+                u'http://ourserver/viewvc/svn/%(path)s/?pathrev=25914',
+            u'svn://anotherserver.com/tools_repository':
+                u'//ourserver/tracs/tools/browser/%(path)s?rev=%(rev)s',
+            u'//server/repo': u'Repo/%(path)s?rev=%(rev)s',
+            u'//server/repo/sub-proj-a':
+                u'SubProjA/%(path)s?rev=%(rev)s'
+            }
+        self.log_messages = []
+        self.log = Mock(debug=lambda msg: self.log_messages.append(msg))
+
+    def test_parse_ini_externals_map(self):
+        ext_map = {}
+        svn_prop.parse_externals_map_ini(ext_map,
+                                         self.ini_section.iteritems(),
+                                         self.log)
+        # No skips
+        self.assertEqual([], self.log_messages)
+        # Could be done better using Python 3.1 assertDictEqual...
+        self.assertEqual(len(self.map_from_ini), len(ext_map))
+        for key, value in self.map_from_ini.iteritems():
+            self.assertTrue(key in ext_map)
+            self.assertEqual(value, ext_map[key])
+
+    def test_null_pattern_link_expansion(self):
+        self.assertTrue(svn_prop.format_external_link(
+                                    ext_map={},
+                                    link=u'http://server/repos1',
+                                    rev=None) is None)
+
+    def test_fully_qualified_pattern_link_expansion(self):
+        self.assertEqual(u'http://trac/proj/browser/Rep2/?rev=%(rev)s',
+                         svn_prop.format_external_link(
+                                    ext_map=self.map_from_ini,
+                                    link=u'svn://server/repos2'))
+        self.assertEqual(u'http://ourserver/viewvc/svn/trunk/?pathrev=25914',
+                         svn_prop.format_external_link(
+                           ext_map=self.map_from_ini,
+                           link=u'http://theirserver.org/svn/eng-soft/trunk/'))
+        self.assertEqual(u'//ourserver/tracs/tools/browser/branches/0.6-qa'
+                         '?rev=%(rev)s',
+                         svn_prop.format_external_link(
+                            ext_map=self.map_from_ini,
+                            link=u'svn://anotherserver.com/tools_repository/'
+                            'branches/0.6-qa'))
+
+    def test_trac_relative_link_expansions(self):
+        self.assertEqual(u'SubProjA/trunk/dev?rev=%(rev)s',
+                         svn_prop.format_external_link(
+                             ext_map=self.map_from_ini,
+                             link=u'//server/repo/sub-proj-a/trunk/dev'))
+        self.assertEqual(u'Repo/sub-proj-b/trunk?rev=%(rev)s',
+                         svn_prop.format_external_link(
+                             ext_map=self.map_from_ini,
+                             link=u'//server/repo/sub-proj-b/trunk'))
+
+    def test_bad_partial_match(self):
+        self.assertTrue(svn_prop.format_external_link(
+                                    ext_map=self.map_from_ini,
+                                    link=u'svn://server/rep',
+                                    rev=None) is None)
+
+    def test_bad_ini(self):
+        bad_ini_sections = [ { '1': u'1-item-is-not-enough' },
+                             { '1': u'3 items-is too-much' }, ]
+        for section in bad_ini_sections:
+            ext_map = {}
+            self.log_messages = []
+            svn_prop.parse_externals_map_ini(ext_map, section.iteritems(),
+                                             self.log)
+            self.assertEqual(1, len(self.log_messages))
+            self.assertEqual(u'svn:externals entry 1 doesn\'t contain a '
+                             'space-separated key value pair, skipping.',
+                             self.log_messages[0])
+
+def suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(SvnExternalsParserTests))
+    suite.addTest(unittest.makeSuite(SvnExternalsRepositoryRelativeLinkTests))
+    suite.addTest(unittest.makeSuite(SvnExternalsUrlBasedLinkTests))
+    suite.addTest(unittest.makeSuite(SvnExternalsMapTests))
+    return suite
+
+if __name__ == '__main__':
+    runner = unittest.TextTestRunner()
+    runner.run(suite())

Property changes on: trac\versioncontrol\tests\svn_prop.py
___________________________________________________________________
Added: svn:mime-type
   + text/x-python
Added: svn:keywords
   + Author Date Id Rev URL
Added: svn:eol-style
   + native

Index: trac/versioncontrol/svn_prop.py
===================================================================
--- trac/versioncontrol/svn_prop.py	(revision 9482)
+++ trac/versioncontrol/svn_prop.py	(working copy)
@@ -17,6 +17,7 @@
 #         Christian Boos <cboos@neuf.fr>
 
 import posixpath
+from urllib import splittype, splithost
 
 from genshi.builder import tag
 
@@ -27,8 +28,216 @@
 from trac.versioncontrol.web_ui.changeset import IPropertyDiffRenderer
 from trac.util import Ranges, to_ranges
 from trac.util.translation import _, tag_
+from trac.web.href import Href
 
+def remprefix(str, prefix):
+    "Removes `prefix` from `str` if `prefix` prefixes `str`."
+    if prefix and str.startswith(prefix):
+        return str[len(prefix):]
+    return str
+    
+def remsuffix(str, suffix):
+    "Removes `suffix` from `str` if `suffix` suffixes `str`."
+    if suffix and str.endswith(suffix):
+        return str[:-len(suffix)]
+    return str
+                
+def parse_ext_line(prop_line, reponame, repopath):
+    """Parse the svn:externals property line `prop_line` and return a
+    dictionary representing the content of the external reference.
+    Returns None for invalid external references or comments.
+    """
+    prop_line = prop_line.strip().replace('\\', '/')
+    if not prop_line or prop_line.startswith('#'):
+        return None
+    elements = prop_line.split()
+    if len(elements) < 2:
+        return None
+    
+    ext_dict = {u'reponame': reponame, u'repopath': repopath}
+    ext_dict[u'rev'] = rev_str = None
+    if u'://' in elements[-1]:
+        # Old-style syntax
+        ext_dict[u'dir'] = elements[0]
+        ext_dict[u'url'] = elements[-1]
+        if len(elements) >= 3:
+            rev_str = ''.join(elements[1:(len(elements) == 4 and 3 or 2)])
+    else:
+        # New-style syntax
+        ext_dict[u'dir'] = elements[-1]
+        ext_dict[u'url'] = elements[-2]
+        if len(elements) >= 3:
+            rev_str = ''.join(elements[0:(len(elements) == 4 and 2 or 1)])
+        elif u'@' in ext_dict[u'url']:
+            ext_dict[u'url'], rev_str = ext_dict[u'url'].split('@')
+            rev_str = u'-r%s' % (rev_str)
+    
+    if rev_str:
+        if not rev_str.startswith(u'-r'):
+            return None
+        if not rev_str[2:].isdigit():
+            return None
+        ext_dict[u'rev'] = int(rev_str[2:])
+        
+    return ext_dict
 
+def parse_externals(prop_str, reponame, repopath):
+    """Parse a svn:externals property string and return a list of external
+    references dictionaries from the valid lines.
+    """
+    return filter(None,
+                  [parse_ext_line(line, reponame, repopath)
+                   for line in prop_str.splitlines()])
+
+def externals_generator(prop_str, reponame, repopath):
+    """Same as parse_externals, only generator form."""
+    for ext_line in prop_str.splitlines():
+        ext = parse_ext_line(ext_line, reponame, repopath)
+        if ext:
+            yield ext
+
+def _convert_dir_rel_to_repo_rel(dir_rel_path, base_repo_path, repo_scope):
+    """Traverse the repository path according to '../'-prefixed `dir_rel_path`
+    and return an equivalent repository-root-relative path.
+    Raise `TracError` if trying to traverse above repository base directory.
+    """
+    up_count = dir_rel_path.count(u'../')
+    path_elements = filter(None, dir_rel_path.replace(u'../', u'').split(u'/'))
+    base_elements = filter(None,
+                           repo_scope.split(u'/') + base_repo_path.split(u'/'))
+    if len(base_elements) < up_count:
+        raise TracError(_(u'Cannot traverse outside '
+                          'repository base directory.'))
+    return u'/'.join([u'^'] + base_elements[0:-up_count] + path_elements)
+
+def get_repo_relative_path(ext_dict, reponame, rm):
+    """Check whether external is within the repository named `reponame` and
+    return path (within `reponame`) to referenced external in case it is.
+    Otherwise return None.
+    """
+    url = ext_dict[u'url']
+    ext_repo = rm.get_repository(ext_dict[u'reponame'])
+    dest_repo = rm.get_repository(reponame)
+    if u'://' in url:
+        # Fully qualified URL
+        return None
+    if ext_repo.get_base() != dest_repo.get_base():
+        # Not same filesystem-repository
+        return None
+    if url.startswith(u'../'):
+        # Relative to directory
+        url = _convert_dir_rel_to_repo_rel(url, ext_dict[u'repopath'],
+                                           ext_repo.scope)
+    if url.startswith(u'^/'):
+        # Relative to repository root
+        repo_url = url[2:]
+        scope = dest_repo.scope.replace(u'\\', u'/').strip(u'/')
+        scope += u'/' if scope else u''
+        if repo_url.startswith(scope):
+            # Target path within current repository
+            # (scoped or not) - generate phase-2 link.
+            return u'%s?rev=%%(rev)s'       \
+                   % (Href(dest_repo.reponame)(remprefix(repo_url, scope)))
+
+def _extract_host_from_url(url):
+    scheme, path = splittype(url or u'')
+    host, path = splithost(path or u'')
+    return (host or u'').lstrip(u'/')
+
+def get_external_url(ext_dict, reponame, rm, req_href=None):
+    """Build a URL for an external definition. If definition already given
+    as URL, return it. Otherwise, use `reponame` URL property to build a URL
+    for the given external. If `reponame` has no URL defined,
+    or the external is not related to that repository, return None.
+    
+    Returned URL is fully-qualified URL string, with or without scheme.
+    """
+    url = ext_dict[u'url']
+    if u'://' in url:
+        # Fully qualified with scheme - it's the answer!
+        return url
+    dest_repo = rm.get_repository(reponame)
+    repo_url = (dest_repo.get_path_url(u'/', None) or u'').rstrip(u'/')
+    if url.startswith(u'//'):
+        # Relative to scheme - partial answer
+        link = url
+        if u'://' in repo_url:
+            # Use repo_url scheme to extend the answer
+            link = u'%s:%s' % (splittype(repo_url)[0], url)
+        elif req_href and u'://' in req_href:
+            # Use request href scheme to extend the answer
+            link = u'%s:%s' % (splittype(req_href)[0], url)
+        return link
+    ext_repo = rm.get_repository(ext_dict[u'reponame'])
+    ext_repo_url = (ext_repo.get_path_url(u'/', None) or u'').rstrip(u'/')
+    if url.startswith(u'/'):
+        # Relative to server root
+        dest_repo_host = _extract_host_from_url(repo_url)
+        ext_repo_host = _extract_host_from_url(ext_repo_url)
+        server_url = link = None
+        if dest_repo_host and (dest_repo_host == ext_repo_host or
+                               ext_repo.get_base() == dest_repo.get_base()):
+            # Repositories related and dest-repos has URL - take its host
+            link = u'//%s%s' % (dest_repo_host, url)
+            server_url = repo_url
+        elif req_href:
+            # Crazy heuristic - hope that target SVN on same host as this Trac
+            link = u'//%s%s' % (_extract_host_from_url(req_href), url)
+            server_url = req_href
+        if u'://' in (server_url or u''):
+            return u'%s:%s' % (splittype(server_url)[0], link)
+        return link
+    if ext_repo.get_base() != dest_repo.get_base():
+        # Repositories are not related
+        return None
+    if not repo_url:
+        # No URL defined
+        return None
+    base_repo_url = remsuffix(repo_url, dest_repo.scope
+                              .replace(u'\\', u'/').rstrip(u'/'))
+    if url.startswith(u'../'):
+        # Relative to directory
+        url = _convert_dir_rel_to_repo_rel(url, ext_dict[u'repopath'],
+                                           ext_repo.scope)
+    if url.startswith(u'^/'):
+        # Relative to repository root
+        return u'%s/%s' % (base_repo_url.rstrip(u'/'),
+                           url[2:].lstrip(u'/'))
+
+def parse_externals_map_ini(ext_map, ini_section, log=None):
+    for dummy, key_value in ini_section:
+        key_value = key_value.split()
+        if len(key_value) != 2:
+            if log:
+                log.debug(u'svn:externals entry %s doesn\'t contain a '
+                          'space-separated key value pair, skipping.' % dummy)
+            continue
+        key, value = key_value
+        if not u'//' in value:
+            # Trac-env-relative link
+            value = remprefix(value.lstrip(u'/'), u'browser/')
+        ext_map[key.rstrip(u'/')] = value.replace('%', '%%')        \
+                                   .replace('$path', '%(path)s')    \
+                                   .replace('$rev', '%(rev)s')
+    return ext_map
+
+def format_external_link(ext_map, link, rev=None):
+    """Lookup the best available match for `link` in `ext_map` (dictionary
+    of externals) and use the map value to replace the link content.
+    Returns a string with the matched URL if a match was found,
+    otherwise - None.
+    """
+    path_elements = []
+    base_url = link.rstrip(u'/')
+    while base_url:
+        if base_url in ext_map:
+            path = u'/'.join(reversed(path_elements))
+            href = ext_map[base_url].replace(u'%(rev)s', u'%%(rev)s')
+            return href % {u'path': path}
+        base_url, suffix = posixpath.split(base_url)
+        path_elements.append(suffix)
+
+
 class SubversionPropertyRenderer(Component):
     implements(IPropertyRenderer)
 
@@ -45,73 +254,90 @@
     
     def render_property(self, name, mode, context, props):
         if name == 'svn:externals':
-            return self._render_externals(props[name])
+            return self._render_externals(props[name], context)
         elif name == 'svn:needs-lock':
             return self._render_needslock(context)
         elif name == 'svn:mergeinfo' or name.startswith('svnmerge-'):
             return self._render_mergeinfo(name, mode, context, props)
 
-    def _render_externals(self, prop):
+    def _render_externals(self, prop, context):
+        rm = RepositoryManager(self.env)
         if not self._externals_map:
-            for dummykey, value in self.config.options('svn:externals'):
-                value = value.split()
-                if len(value) != 2:
-                    self.log.warn("svn:externals entry %s doesn't contain "
-                            "a space-separated key value pair, skipping.", 
-                            dummykey)
-                    continue
-                key, value = value
-                self._externals_map[key] = value.replace('%', '%%') \
-                                           .replace('$path', '%(path)s') \
-                                           .replace('$rev', '%(rev)s')
-        externals = []
-        for external in prop.splitlines():
-            elements = external.split()
-            if not elements:
+            parse_externals_map_ini(self._externals_map,
+                                    self.config.options('svn:externals'),
+                                    self.log)
+        externals_data = []
+
+        def add_link(ext, link):
+            if link is None:
+                # No match
+                externals_data.append(((ext[u'dir'], ext[u'url'] or '',
+                                        ext[u'rev'] or ''), u''))
+                return
+            if not u'//' in link:
+                # Phase-2
+                link = u'/browser/%s' % (link)
+            link = remsuffix(link % {u'rev': ext[u'rev'] or u''}, u'?rev=')
+            externals_data.append(((ext[u'dir'], ext[u'url'] or '',
+                                    ext[u'rev'] or ''), link))
+
+        reponame = context.resource.parent.id
+        repopath = context.resource.id.replace(u'\\', u'/').strip(u'/')
+        repolist = [reponame] + [repo.reponame
+                                 for repo in rm.get_real_repositories()
+                                 if repo.reponame != reponame]
+        req_href = context.req.abs_href('/')
+        for ext in externals_generator(prop, reponame, repopath):
+            for name in repolist:
+                link = get_repo_relative_path(ext, name, rm)
+                if link:
+                    # Got phase-2 link - all done.
+                    break
+            if link:
+                add_link(ext, link)
                 continue
-            localpath, rev, url = elements[0], '', elements[-1]
-            if localpath.startswith('#'):
-                externals.append((external, None, None, None, None))
+            last_phase1 = None
+            for name in repolist:
+                phase1_link = get_external_url(ext, name, rm, req_href)
+                if phase1_link:
+                    last_phase1 = phase1_link
+                    # Try converting to phase-2 link using repositories
+                    for othername in repolist:
+                        otherrepo = rm.get_repository(othername)
+                        link = format_external_link(
+                            {(otherrepo.get_path_url(u'/', None) or u'')
+                             .rstrip(u'/'):
+                             u'%s/%%(path)s?rev=%%(rev)s' % othername},
+                             phase1_link)
+                        if link:
+                            break
+                    if not link:
+                        # Try matching link with externals map
+                        link = format_external_link(self._externals_map,
+                                                    phase1_link)
+                    if link:
+                        break
+            link = link or last_phase1
+            if link:
+                add_link(ext, link)
                 continue
-            if len(elements) == 3:
-                rev = elements[1]
-                rev = rev.replace('-r', '')
-            # retrieve a matching entry in the externals map
-            prefix = []
-            base_url = url
-            while base_url:
-                if base_url in self._externals_map or base_url == u'/':
-                    break
-                base_url, pref = posixpath.split(base_url)
-                prefix.append(pref)
-            href = self._externals_map.get(base_url)
-            revstr = rev and ' at revision '+rev or ''
-            if not href and (url.startswith('http://') or 
-                             url.startswith('https://')):
-                href = url.replace('%', '%%')
-            if href:
-                remotepath = ''
-                if prefix:
-                    remotepath = posixpath.join(*reversed(prefix))
-                externals.append((localpath, revstr, base_url, remotepath,
-                                  href % {'path': remotepath, 'rev': rev}))
+            add_link(ext, None)
+                
+        # Finish up with actually composing the externals table
+        trs = []
+        for label, href in externals_data:
+            if not href:
+                tr = tag.tr(tag.td(tag.a(label[0], title=
+                                _('No matching external')),
+                            tag.td(label[1]),
+                            tag.td(label[2])))
             else:
-                externals.append((localpath, revstr, url, None, None))
-        externals_data = []
-        for localpath, rev, url, remotepath, href in externals:
-            label = localpath
-            if url is None:
-                title = ''
-            elif href:
-                if url:
-                    url = ' in ' + url
-                label += rev + url
-                title = ''.join((remotepath, rev, url))
-            else:
-                title = _('No svn:externals configured in trac.ini')
-            externals_data.append((label, href, title))
-        return tag.ul([tag.li(tag.a(label, href=href, title=title))
-                       for label, href, title in externals_data])
+                tr = tag.tr(tag.td(tag.a(label[0], href=href,
+                                   title=_('Jump to external'))),
+                            tag.td(tag.a(label[1], href=href)),
+                            tag.td(tag.a(label[2], href=href)))
+            trs.append(tr)
+        return tag.table(tag.tbody(trs))
 
     def _render_needslock(self, context):
         return tag.img(src=context.href.chrome('common/lock-locked.png'),

