Index: tests/__init__.py
===================================================================
--- tests/__init__.py	(revision 9482)
+++ 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: tests/svn_prop.py
===================================================================
--- tests/svn_prop.py	(revision 0)
+++ tests/svn_prop.py	(revision 0)
@@ -0,0 +1,901 @@
+# -*- 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 setUp(self):
+        self.myrep = Mock(reponame='myrep')
+
+    def test_no_externals(self):
+        externals_prop = ''
+        externals_list = svn_prop.parse_externals(externals_prop,
+                    self.myrep, repopath='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 = """
+            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,
+                    self.myrep, repopath='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['repo'], self.myrep)
+            self.assertEqual(ext_dict['repopath'],
+                             'dev/trunk/src/my-module')
+        simple, spaceless, spaced = externals_list
+        self.assertEqual(simple['dir'], 'simple')
+        self.assertEqual(simple['url'],
+                         'http://example.org/svn/repos/dir1')
+        self.assertTrue(simple['rev'] is None)
+        self.assertEqual(spaceless['dir'], 'spaceless_rev')
+        self.assertEqual(spaceless['url'],
+                         'svn://example.org/svn/repos/dir2')
+        self.assertEqual(10, spaceless['rev'])
+        self.assertEqual(spaced['dir'], 'spaced_rev')
+        self.assertEqual(spaced['url'],
+                         '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,
+                    self.myrep, repopath='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['repo'], self.myrep)
+            self.assertEqual(ext_dict['repopath'],
+                             'dev/trunk/src/my-module')
+        simple, spaceless, spaced, pegged = externals_list
+        self.assertEqual(simple['dir'], 'simple')
+        self.assertEqual(simple['url'],
+                         'https://example.org/svn/repos/dir1')
+        self.assertTrue(simple['rev'] is None)
+        self.assertEqual(spaceless['dir'], 'spaceless_rev')
+        self.assertEqual(spaceless['url'],
+                         '^/repos/dir2')
+        self.assertEqual(10, spaceless['rev'])
+        self.assertEqual(spaced['dir'], 'spaced_rev')
+        self.assertEqual(spaced['url'],
+                         '//example.org/svn/repos/dir3')
+        self.assertEqual(12, spaced['rev'])
+        self.assertEqual(pegged['dir'], 'peg_rev')
+        self.assertEqual(pegged['url'],
+                         '../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 = 'svn:random-uuid:/var/svn/repos'
+        self.other_repo_base = 'svn:random-uuid:/var/svn/repos-2'
+        self.repositories = {
+            'myrep': Mock(reponame='myrep', scope='/',
+                           get_base=lambda: self.repo_base,
+                           get_path_url=lambda path, rev: None),
+            'my-devel-rep': Mock(reponame='my-devel-rep', scope='/devel/',
+                                   get_base=lambda: self.repo_base,
+                                   get_path_url=lambda path, rev: None),
+            'my-prod-rep': Mock(reponame='my-prod-rep',
+                                 scope='/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.
+            'my-dev-rep': Mock(reponame='my-dev-rep', scope='/dev/',
+                                 get_base=lambda: self.repo_base,
+                                 get_path_url=lambda path, rev: None),
+            'otherrep': Mock(reponame='otherrep', scope='/',
+                              get_base=lambda: self.other_repo_base,
+                              get_path_url=lambda path, rev: None),
+            }
+
+    def test_repository_relative_url(self):
+        external = {'repo': self.repositories['myrep'],
+                    'repopath': 'devel/my-prod/trunk/src/my-module',
+                    'dir': 'local',
+                    'url': '^/devel/common/trunk/log',
+                    'rev': None}
+        scoped_external = {'repo': self.repositories['my-devel-rep'],
+                           # Note the missing 'devel/' in the repo-path
+                           'repopath': 'my-prod/trunk/src/my-module',
+                           'dir': 'local',
+                           'url': '^/devel/common/trunk/log',
+                           'rev': None}
+        deep_external = {'repo': self.repositories['my-devel-rep'],
+                         # Note the missing 'devel/my-prod/' in the repo-path
+                         'repopath': 'trunk/src/my-module',
+                         'dir': 'local',
+                         'url': '^/devel/common/trunk/log',
+                         'rev': None}
+        
+        for ext in [external, scoped_external, deep_external]:
+            # Un-scoped repository asserts
+            repo = self.repositories['myrep']
+            for scope in ['', '/']:
+                repo.scope = scope
+                # External as seen from same repository
+                self.assertEqual('devel/common/trunk/log',
+                    svn_prop.get_repo_relative_path(ext, repo))
+            # Scoped repository asserts
+            repo = self.repositories['my-devel-rep']
+            for scope in ['devel', '/devel', 'devel/', '/devel/']:
+                repo.scope = scope
+                # External as seen from the un-scoped repository
+                # Note the missing '/devel/' in the expected href
+                self.assertEqual('common/trunk/log',
+                        svn_prop.get_repo_relative_path(ext, repo))
+            # Deep-scoped repository (too deep for common)
+            self.assertTrue(svn_prop.get_repo_relative_path(ext,
+                                self.repositories['my-prod-rep']) is None)
+            # Differently-scoped repository (out of range)
+            self.assertTrue(svn_prop.get_repo_relative_path(ext,
+                                self.repositories['my-dev-rep']) is None)
+            # Another repository, not related to external
+            self.assertTrue(svn_prop.get_repo_relative_path(ext,
+                                self.repositories['otherrep']) is None)
+
+    def test_directory_relative_url(self):
+        external = {'repo': self.repositories['myrep'],
+                    'repopath': 'devel/my-prod/trunk/src/my-module',
+                    'dir': 'local',
+                    'url': '../../../../common/trunk/log',
+                    'rev': None}
+        scoped_external = {'repo': self.repositories['my-devel-rep'],
+                           # Note the missing 'devel/' in the repo-path
+                           'repopath': 'my-prod/trunk/src/my-module',
+                           'dir': 'local',
+                           'url': '../../../../common/trunk/log',
+                           'rev': None}
+        deep_external = {'repo': self.repositories['my-prod-rep'],
+                         # Note the missing 'devel/my-prod/' in the repo-path
+                         'repopath': 'trunk/src/my-module',
+                         'dir': 'local',
+                         # Going even deeper just for fun (and test of course)
+                         'url': '../../../../../devel/common/trunk/log',
+                         'rev': None}
+
+        for ext in [external, scoped_external, deep_external]:
+            # Un-scoped repository asserts
+            self.assertEqual('devel/common/trunk/log',
+                             svn_prop.get_repo_relative_path(ext,
+                                                 self.repositories['myrep']))
+            # Scoped repository asserts
+            self.assertEqual('common/trunk/log',
+                             svn_prop.get_repo_relative_path(ext,
+                                         self.repositories['my-devel-rep']))
+            # Deep-scoped repository asserts (out of reach)
+            self.assertTrue(svn_prop.get_repo_relative_path(ext,
+                                self.repositories['my-prod-rep']) is None)
+            # Unrelated repository asserts
+            self.assertTrue(svn_prop.get_repo_relative_path(ext,
+                                    self.repositories['otherrep']) is None)
+
+    def test_invalid_directory_relative_url(self):
+        external = {'repo': self.repositories['myrep'],
+                    'repopath': 'devel/my-prod/trunk/src/my-module',
+                    'dir': 'local',
+                    # Out of repository base! aaah!
+                    'url': '../../../../../../common/trunk/log',
+                    'rev': None}
+        scoped_external = {'repo': self.repositories['my-devel-rep'],
+                           # Note the missing 'devel/' in the repo-path
+                           'repopath': 'my-prod/trunk/src/my-module',
+                           'dir': 'local',
+                           'url': '../../../../../../common/trunk/log',
+                           'rev': None}
+        deep_external = {'repo': self.repositories['my-prod-rep'],
+                         # Note the missing 'devel/my-prod/' in the repo-path
+                         'repopath': 'trunk/src/my-module',
+                         'dir': 'local',
+                         # Going even deeper just for fun (and test of course)
+                         'url': '../../../../../../../devel/common/trunk/log',
+                         'rev': None}
+        
+        for ext in [external, scoped_external, deep_external]:
+            self.assertRaises(TracError, svn_prop.get_repo_relative_path,
+                              ext, self.repositories['myrep'])
+            self.assertRaises(TracError, svn_prop.get_repo_relative_path,
+                              ext, self.repositories['my-devel-rep'])
+            self.assertRaises(TracError, svn_prop.get_repo_relative_path,
+                              ext, self.repositories['my-prod-rep'])
+            self.assertTrue(svn_prop.get_repo_relative_path(ext,
+                              self.repositories['otherrep']) is None)
+
+class SvnExternalsUrlBasedLinkTests(unittest.TestCase):
+
+    def setUp(self):
+        self.repo_base = 'svn:random-uuid:/var/svn/repos'
+        self.repo_host = 'example.org'
+        self.repo_base_url = '//%s/svn/myrep' % (self.repo_host)
+        self.other_repo_base = 'svn:random-uuid:/var/svn/repos-2'
+        self.other_repo_base_url = '//%s/svn/otherrep' % (self.repo_host)
+        self.repositories = {
+            'myrep': Mock(reponame='myrep', scope='/',
+                           get_base=lambda: self.repo_base,
+                           get_path_url=lambda path, rev: 'https:%s/%s' %
+                           (self.repo_base_url, path.lstrip('/'))),
+            'my-devel-rep': Mock(reponame='my-devel-rep', scope='/devel/',
+                                  get_base=lambda: self.repo_base,
+                                  get_path_url=lambda path, rev:
+                                      'http:%s/devel/%s' %
+                                      (self.repo_base_url, path.lstrip('/'))),
+            'my-prod-rep': Mock(reponame='my-prod-rep',
+                                 scope='/devel/my-prod/',
+                                 get_base=lambda: self.repo_base,
+                                 get_path_url=lambda path, rev:
+                                      'svn+ssh:%s/devel/my-prod/%s' %
+                                      (self.repo_base_url, path.lstrip('/'))),
+            # Note 'my-dev-rep' purposefully has a scope that prefixes
+            # 'my-devel-rep'. This tests cases of false matching scopes.
+            'my-dev-rep': Mock(reponame='my-dev-rep', scope='/dev/',
+                                get_base=lambda: self.repo_base,
+                                get_path_url=lambda path, rev: '%s/dev/%s' %
+                                      (self.repo_base_url, path.lstrip('/'))),
+            # Extra repository, same base as above, without URL definition
+            'my-nourl-rep': Mock(reponame='my-nourl-rep', scope='/devel/',
+                                  get_base=lambda: self.repo_base,
+                                  get_path_url=lambda path, rev: None),
+            'otherrep': Mock(reponame='otherrep', scope='/',
+                              get_base=lambda: self.other_repo_base,
+                              get_path_url=lambda path, rev: 'svn:%s/%s' %
+                                (self.other_repo_base_url, path.lstrip('/'))),
+            }
+
+    def test_fully_qualified_and_scheme_relative_url(self):
+        external = {'repo': self.repositories['myrep'],
+                    'repopath': 'devel/my-prod/trunk/src/my-module',
+                    'dir': 'local',
+                    'rev': None}
+        req_scheme = random.choice(['http', 'https'])
+        req_host = 'local.server'
+        req_href = '%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, '', '/'])
+
+        ext_url = '//remote.server.org/third/party/library'
+        for scheme in ['', 'http:', 'https:', 'svn:', 'svn+ssh:']:
+            external['url'] = '%s%s' % (scheme, ext_url)
+            for reponame, repo in self.repositories.iteritems():
+                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.repositories[reponame]              \
+                               .get_path_url('/', None) or ''
+                    if '://' in repo_url:
+                        # Inherit scheme from Repository URL
+                        expected_scheme = '%s:' % (splittype(repo_url)[0])
+                    elif use_req_href and '://' in use_req_href:
+                        # Inherit scheme from request
+                        expected_scheme = '%s:' % (req_scheme)
+                self.assertEqual('%s%s' % (expected_scheme, ext_url),
+                                 svn_prop.get_external_url(external, repo,
+                                                       req_href=use_req_href))
+
+    def test_server_relative_url(self):
+        external = {'repo': self.repositories['myrep'],
+                    'repopath': 'devel/my-prod/trunk/src/my-module',
+                    'dir': 'local',
+                    # External from myrep points to otherrep via server root!
+                    'url': '/svn/otherrep/dev',
+                    'rev': None}
+        req_scheme = random.choice(['http', 'https'])
+        req_host = 'local.server'
+        req_href = '%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, '', '/'])
+        use_req_href = req_href
+        
+        for reponame, repo in self.repositories.iteritems():
+            repo_url = self.repositories[reponame]       \
+                       .get_path_url('/', None) or ''
+            expected_link = None
+            if repo_url:
+                expected_link = '//%s%s' % (self.repo_host, external['url'])
+                if '://' in repo_url:
+                    expected_link = '%s:%s' % (splittype(repo_url)[0],
+                                                expected_link)
+            elif (use_req_href or '').rstrip('/'):
+                expected_link = '%s://%s%s' % (req_scheme, req_host,
+                                                external['url'])
+            if expected_link:
+                self.assertEqual(expected_link,
+                                 svn_prop.get_external_url(external, repo,
+                                               req_href=use_req_href))
+            else:
+                self.assertTrue(svn_prop.get_external_url(external, repo,
+                                               req_href=use_req_href) is None)
+    
+    def test_repository_relative_url(self):
+        external = {'repo': self.repositories['myrep'],
+                    'repopath': 'devel/my-prod/trunk/src/my-module',
+                    'dir': 'local',
+                    'url': '^/devel/common/trunk/log',
+                    'rev': None}
+        scoped_external = {'repo': self.repositories['my-devel-rep'],
+                           # Note the missing 'devel/' in the repo-path
+                           'repopath': 'my-prod/trunk/src/my-module',
+                           'dir': 'local',
+                           'url': '^/devel/common/trunk/log',
+                           'rev': None}
+        deep_external = {'repo': self.repositories['my-devel-rep'],
+                         # Note the missing 'devel/my-prod/' in the repo-path
+                         'repopath': 'trunk/src/my-module',
+                         'dir': 'local',
+                         'url': '^/devel/common/trunk/log',
+                         'rev': None}
+
+        expected_link = '%s/devel/common/trunk/log' % (self.repo_base_url)
+        for ext in [external, scoped_external, deep_external]:
+            self.assertEqual('https:%s' % (expected_link),
+                             svn_prop.get_external_url(ext,
+                                  self.repositories['myrep']))
+            self.assertEqual('http:%s' % (expected_link),
+                             svn_prop.get_external_url(ext,
+                                  self.repositories['my-devel-rep']))
+            self.assertEqual('svn+ssh:%s' % (expected_link),
+                             svn_prop.get_external_url(ext,
+                                  self.repositories['my-prod-rep']))
+            self.assertEqual('%s' % (expected_link),
+                             svn_prop.get_external_url(ext,
+                                  self.repositories['my-dev-rep']))
+            self.assertTrue(svn_prop.get_external_url(ext,
+                                  self.repositories['my-nourl-rep']) is None)
+            self.assertTrue(svn_prop.get_external_url(ext,
+                                  self.repositories['otherrep']) is None)
+
+    def test_directory_relative_url(self):
+        external = {'repo': self.repositories['myrep'],
+                    'repopath': 'devel/my-prod/trunk/src/my-module',
+                    'dir': 'local',
+                    'url': '../../../../common/trunk/log',
+                    'rev': None}
+        scoped_external = {'repo': self.repositories['my-devel-rep'],
+                           # Note the missing 'devel/' in the repo-path
+                           'repopath': 'my-prod/trunk/src/my-module',
+                           'dir': 'local',
+                           'url': '../../../../common/trunk/log',
+                           'rev': None}
+        deep_external = {'repo': self.repositories['my-prod-rep'],
+                         # Note the missing 'devel/my-prod/' in the repo-path
+                         'repopath': 'trunk/src/my-module',
+                         'dir': 'local',
+                         # Going even deeper just for fun (and test of course)
+                         'url': '../../../../../devel/common/trunk/log',
+                         'rev': None}
+
+        expected_link = '%s/devel/common/trunk/log' % (self.repo_base_url)
+        for ext in [external, scoped_external, deep_external]:
+            self.assertEqual('https:%s' % (expected_link),
+                             svn_prop.get_external_url(ext,
+                                  self.repositories['myrep']))
+            self.assertEqual('http:%s' % (expected_link),
+                             svn_prop.get_external_url(ext,
+                                  self.repositories['my-devel-rep']))
+            self.assertEqual('svn+ssh:%s' % (expected_link),
+                             svn_prop.get_external_url(ext,
+                                  self.repositories['my-prod-rep']))
+            self.assertEqual('%s' % (expected_link),
+                             svn_prop.get_external_url(ext,
+                                  self.repositories['my-dev-rep']))
+            self.assertTrue(svn_prop.get_external_url(ext,
+                                  self.repositories['my-nourl-rep']) is None)
+            self.assertTrue(svn_prop.get_external_url(ext,
+                                  self.repositories['otherrep']) is None)
+
+    def test_invalid_directory_relative_url(self):
+        external = {'repo': self.repositories['myrep'],
+                    'repopath': 'devel/my-prod/trunk/src/my-module',
+                    'dir': 'local',
+                    # Out of repository base! aaah!
+                    'url': '../../../../../../common/trunk/log',
+                    'rev': None}
+        scoped_external = {'repo': self.repositories['my-devel-rep'],
+                           # Note the missing 'devel/' in the repo-path
+                           'repopath': 'my-prod/trunk/src/my-module',
+                           'dir': 'local',
+                           'url': '../../../../../../common/trunk/log',
+                           'rev': None}
+        deep_external = {'repo': self.repositories['my-prod-rep'],
+                        # Note the missing 'devel/my-prod/' in the repo-path
+                        'repopath': 'trunk/src/my-module',
+                        'dir': 'local',
+                        # Going even deeper just for fun (and test of course)
+                        'url': '../../../../../../../devel/common/trunk/log',
+                        'rev': None}
+        
+        for ext in [external, scoped_external, deep_external]:
+            self.assertRaises(TracError, svn_prop.get_external_url,
+                              ext, self.repositories['myrep'])
+            self.assertRaises(TracError, svn_prop.get_external_url,
+                              ext, self.repositories['my-devel-rep'])
+            self.assertRaises(TracError, svn_prop.get_external_url,
+                              ext, self.repositories['my-prod-rep'])
+            self.assertTrue(svn_prop.get_external_url(ext,
+                              self.repositories['otherrep']) is None)
+
+class SvnExternalsMapTests(unittest.TestCase):
+
+    def setUp(self):
+        self.ini_section = {
+            '1': '//server/repos1 '
+            'http://trac/proj/browser/Rep1/$path?rev=$rev',
+            '2': 'svn://server/repos2/ '
+            'http://trac/proj/browser/Rep2/$path?rev=$rev',
+            '3': 'http://theirserver.org/svn/eng-soft '
+            'http://ourserver/viewvc/svn/$path/?pathrev=25914',
+            '4': 'svn://anotherserver.com/tools_repository '
+            '//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': '//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': '//server/repo/sub-proj-a '
+            '/browser/SubProjA/$path?rev=$rev'
+            }
+        self.map_from_ini = {
+            '//server/repos1':
+                'http://trac/proj/browser/Rep1/%(path)s?rev=%(rev)s',
+            'svn://server/repos2':
+                'http://trac/proj/browser/Rep2/%(path)s?rev=%(rev)s',
+            'http://theirserver.org/svn/eng-soft':
+                'http://ourserver/viewvc/svn/%(path)s/?pathrev=25914',
+            'svn://anotherserver.com/tools_repository':
+                '//ourserver/tracs/tools/browser/%(path)s?rev=%(rev)s',
+            '//server/repo': 'Repo/%(path)s?rev=%(rev)s',
+            '//server/repo/sub-proj-a':
+                '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='http://server/repos1',
+                                    rev=None) is None)
+
+    def test_fully_qualified_pattern_link_expansion(self):
+        self.assertEqual('http://trac/proj/browser/Rep1/trunk?rev=%(rev)s',
+                         svn_prop.format_external_link(
+                            ext_map=self.map_from_ini,
+                            link='http://server/repos1/trunk'))
+        self.assertEqual('http://trac/proj/browser/Rep2/?rev=%(rev)s',
+                         svn_prop.format_external_link(
+                                    ext_map=self.map_from_ini,
+                                    link='svn://server/repos2'))
+        self.assertEqual('http://ourserver/viewvc/svn/trunk/?pathrev=25914',
+                         svn_prop.format_external_link(
+                           ext_map=self.map_from_ini,
+                           link='http://theirserver.org/svn/eng-soft/trunk/'))
+        self.assertEqual('//ourserver/tracs/tools/browser/branches/0.6-qa'
+                         '?rev=%(rev)s',
+                         svn_prop.format_external_link(
+                            ext_map=self.map_from_ini,
+                            link='svn://anotherserver.com/tools_repository/'
+                            'branches/0.6-qa'))
+
+    def test_trac_relative_link_expansions(self):
+        self.assertEqual('SubProjA/trunk/dev?rev=%(rev)s',
+                         svn_prop.format_external_link(
+                             ext_map=self.map_from_ini,
+                             link='//server/repo/sub-proj-a/trunk/dev'))
+        self.assertEqual('Repo/sub-proj-b/trunk?rev=%(rev)s',
+                         svn_prop.format_external_link(
+                             ext_map=self.map_from_ini,
+                             link='//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='svn://server/rep',
+                                    rev=None) is None)
+
+    def test_bad_ini(self):
+        bad_ini_sections = [ { '1': '1-item-is-not-enough' },
+                             { '1': '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('svn:externals entry 1 doesn\'t contain a '
+                             'space-separated key value pair, skipping.',
+                             self.log_messages[0])
+
+class SvnExternalsIntegrationTests(unittest.TestCase):
+
+    def setUp(self):
+        ini_section = {
+            '1': '//old-server:8000/svn/myrep '
+            '/browser/myrep/$path?rev=$rev',
+            '2': 'svn://other-server/repo/sub-proj-a/ '
+            '//ourserver/viewvc/svn/$path?pathrev=$rev'
+            }
+        self.ext_map = {}
+        svn_prop.parse_externals_map_ini(self.ext_map, ini_section.iteritems())
+        self.repo_base = 'svn:random-uuid:/var/svn/repos'
+        self.repo_host = 'example.org'
+        self.repo_base_url = '//%s/svn/myrep' % (self.repo_host)
+        self.other_repo_base = 'svn:random-uuid:/var/svn/repos-2'
+        self.other_repo_base_url = '//%s/svn/otherrep' % (self.repo_host)
+        self.myrep = Mock(reponame='myrep', scope='/',
+                          get_base=lambda: self.repo_base,
+                          get_path_url=lambda path, rev: 'https:%s/%s' %
+                          (self.repo_base_url, path.lstrip('/')))
+        self.mydevelrep = Mock(reponame='my-devel-rep', scope='/devel/',
+                               get_base=lambda: self.repo_base,
+                               get_path_url=lambda path, rev:
+                                   'http:%s/devel/%s' %
+                                   (self.repo_base_url, path.lstrip('/')))
+        self.myprodrep = Mock(reponame='my-prod-rep',
+                              scope='/devel/my-prod/',
+                              get_base=lambda: self.repo_base,
+                              get_path_url=lambda path, rev:
+                                   'svn+ssh:%s/devel/my-prod/%s' %
+                                   (self.repo_base_url, path.lstrip('/')))
+        self.mydevrep = Mock(reponame='my-dev-rep', scope='/dev/',
+                             get_base=lambda: self.repo_base,
+                             get_path_url=lambda path, rev: '%s/dev/%s' %
+                                   (self.repo_base_url, path.lstrip('/')))
+        # Extra repository, same base as above, without URL definition
+        self.mynourlrep = Mock(reponame='my-nourl-rep', scope='/devel/',
+                               get_base=lambda: self.repo_base,
+                               get_path_url=lambda path, rev: None)
+        self.otherrep = Mock(reponame='otherrep', scope='/',
+                             get_base=lambda: self.other_repo_base,
+                             get_path_url=lambda path, rev: 'svn:%s/%s' %
+                               (self.other_repo_base_url, path.lstrip('/')))
+
+        self.req_host = '//example.org'
+        self.req_href_no_scheme = '%s/trac/site' % (self.req_host)
+        self.req_scheme = random.choice(['http', 'https'])
+        self.req_href = '%s:%s' % (self.req_scheme, self.req_href_no_scheme)
+        self.ext_prop_str = """
+            OldSyntax http://old-server:8000/svn/myrep/devel/common/trunk
+            http://example.org/svn/myrep/dev@1011 NewSyntax
+            //example.org/svn/myrep/devel/common/trunk SchemeRel
+            /svn/otherrep/dev ServerRel
+            -r137 ^/devel/common/trunk RepoRel
+            ../../../../devel/common/trunk/log DirRel
+            ../../../../dev FarDirRel
+            ../../../../../../../var/.htpasswd IllegalDirRel
+            -r 6 svn://other-server/repo/sub-proj-a/ RemoteProject
+            """
+
+    def test_externals_on_one_unscoped_repository(self):
+        externals = svn_prop.parse_externals(self.ext_prop_str, self.myrep,
+                                             'devel/my-prod/trunk/src')
+        self.assertTrue(isinstance(externals, list))
+        self.assertEqual(9, len(externals))
+        self.assertEqual('OldSyntax', externals[0]['dir'])
+        self.assertEqual({'phase': 2, 'reponame': 'myrep',
+                          'href': 'devel/common/trunk', 'rev': None},
+                         svn_prop.get_link(externals[0], [self.myrep],
+                                           self.req_href, self.ext_map))
+        self.assertEqual('NewSyntax', externals[1]['dir'])
+        self.assertEqual({'phase': 2, 'reponame': 'myrep',
+                          'href': 'dev', 'rev': 1011},
+                         svn_prop.get_link(externals[1], [self.myrep],
+                                           self.req_href, self.ext_map))
+        self.assertEqual('SchemeRel', externals[2]['dir'])
+        self.assertEqual({'phase': 2, 'reponame': 'myrep',
+                          'href': 'devel/common/trunk', 'rev': None},
+                         svn_prop.get_link(externals[2], [self.myrep],
+                                           self.req_href, self.ext_map))
+        self.assertEqual('ServerRel', externals[3]['dir'])
+        self.assertEqual({'phase': 1, 'href': 'https:%s/svn/otherrep/dev' %
+                          (self.req_host), 'rev': None},
+                         svn_prop.get_link(externals[3], [self.myrep],
+                                           self.req_href, self.ext_map))
+        self.assertEqual('RepoRel', externals[4]['dir'])
+        self.assertEqual({'phase': 2, 'reponame': 'myrep',
+                          'href': 'devel/common/trunk', 'rev': 137},
+                         svn_prop.get_link(externals[4], [self.myrep],
+                                           self.req_href, self.ext_map))
+        self.assertEqual('DirRel', externals[5]['dir'])
+        self.assertEqual({'phase': 2, 'reponame': 'myrep',
+                          'href': 'devel/common/trunk/log', 'rev': None},
+                         svn_prop.get_link(externals[5], [self.myrep],
+                                           self.req_href, self.ext_map))
+        self.assertEqual('FarDirRel', externals[6]['dir'])
+        self.assertEqual({'phase': 2, 'reponame': 'myrep',
+                          'href': 'dev', 'rev': None},
+                         svn_prop.get_link(externals[6], [self.myrep],
+                                           self.req_href, self.ext_map))
+        self.assertEqual('IllegalDirRel', externals[7]['dir'])
+        self.assertRaises(TracError, svn_prop.get_link, externals[7],
+                                   [self.myrep], self.req_href, self.ext_map)
+        self.assertEqual('RemoteProject', externals[8]['dir'])
+        self.assertEqual({'phase': 1, 'href': '//ourserver/viewvc/svn/'
+                          '?pathrev=6', 'rev': 6},
+                         svn_prop.get_link(externals[8], [self.myrep],
+                                           self.req_href, self.ext_map))
+
+    def test_externals_on_one_scoped_repository(self):
+        externals = svn_prop.parse_externals(self.ext_prop_str,
+                                             self.mydevelrep,
+                                             'my-prod/trunk/src')
+        self.assertTrue(isinstance(externals, list))
+        self.assertEqual(9, len(externals))
+        self.assertEqual('OldSyntax', externals[0]['dir'])
+        self.assertEqual({'phase': 1, 'href': 'http://old-server:8000/svn'
+                          '/myrep/devel/common/trunk', 'rev': None},
+                         svn_prop.get_link(externals[0], [self.mydevelrep],
+                                           self.req_href, self.ext_map))
+        self.assertEqual('NewSyntax', externals[1]['dir'])
+        self.assertEqual({'phase': 1, 'rev': 1011, 
+                          'href': 'http://example.org/svn/myrep/dev'},
+                         svn_prop.get_link(externals[1], [self.mydevelrep],
+                                           self.req_href, self.ext_map))
+        self.assertEqual('SchemeRel', externals[2]['dir'])
+        self.assertEqual({'phase': 2, 'reponame': 'my-devel-rep',
+                          'href': 'common/trunk', 'rev': None},
+                         svn_prop.get_link(externals[2], [self.mydevelrep],
+                                           self.req_href, self.ext_map))
+        self.assertEqual('ServerRel', externals[3]['dir'])
+        self.assertEqual({'phase': 1, 'href': 'http:%s/svn/otherrep/dev' %
+                          (self.req_host), 'rev': None},
+                         svn_prop.get_link(externals[3], [self.mydevelrep],
+                                           self.req_href, self.ext_map))
+        self.assertEqual('RepoRel', externals[4]['dir'])
+        self.assertEqual({'phase': 2, 'reponame': 'my-devel-rep',
+                          'href': 'common/trunk', 'rev': 137},
+                         svn_prop.get_link(externals[4], [self.mydevelrep],
+                                           self.req_href, self.ext_map))
+        self.assertEqual('DirRel', externals[5]['dir'])
+        self.assertEqual({'phase': 2, 'reponame': 'my-devel-rep',
+                          'href': 'common/trunk/log', 'rev': None},
+                         svn_prop.get_link(externals[5], [self.mydevelrep],
+                                           self.req_href, self.ext_map))
+        self.assertEqual('FarDirRel', externals[6]['dir'])
+        self.assertEqual({'phase': 1, 'href': 'http:%s/svn/myrep/dev' %
+                          (self.req_host), 'rev': None},
+                         svn_prop.get_link(externals[6], [self.mydevelrep],
+                                       self.req_href, self.ext_map))
+        self.assertEqual('IllegalDirRel', externals[7]['dir'])
+        self.assertRaises(TracError, svn_prop.get_link, externals[7],
+                               [self.mydevelrep], self.req_href, self.ext_map)
+        self.assertEqual('RemoteProject', externals[8]['dir'])
+        self.assertEqual({'phase': 1, 'href': '//ourserver/viewvc/svn/'
+                          '?pathrev=6', 'rev': 6},
+                         svn_prop.get_link(externals[8], [self.myrep],
+                                           self.req_href, self.ext_map))
+
+    def test_externals_on_several_scoped_crossing_repositories(self):
+        repo_list = [self.myprodrep, self.mydevelrep,
+                     self.mydevrep, self.otherrep]
+        externals = svn_prop.parse_externals(self.ext_prop_str, self.myprodrep,
+                                             'trunk/src')
+        self.assertTrue(isinstance(externals, list))
+        self.assertEqual(9, len(externals))
+        self.assertEqual('OldSyntax', externals[0]['dir'])
+        self.assertEqual({'phase': 1, 'href': 'http://old-server:8000/svn'
+                          '/myrep/devel/common/trunk', 'rev': None},
+                         svn_prop.get_link(externals[0], repo_list,
+                                           self.req_href, self.ext_map))
+        self.assertEqual('NewSyntax', externals[1]['dir'])
+        self.assertEqual({'phase': 1, 'rev': 1011, 
+                          'href': 'http://example.org/svn/myrep/dev'},
+                         svn_prop.get_link(externals[1], repo_list,
+                                           self.req_href, self.ext_map))
+        self.assertEqual('SchemeRel', externals[2]['dir'])
+        self.assertEqual({'phase': 2, 'reponame': 'my-devel-rep',
+                          'href': 'common/trunk', 'rev': None},
+                         svn_prop.get_link(externals[2], repo_list,
+                                           self.req_href, self.ext_map))
+        self.assertEqual('ServerRel', externals[3]['dir'])
+        self.assertEqual({'phase': 2, 'reponame': 'otherrep',
+                          'href': 'dev', 'rev': None},
+                         svn_prop.get_link(externals[3], repo_list,
+                                           self.req_href, self.ext_map))
+        self.assertEqual('RepoRel', externals[4]['dir'])
+        self.assertEqual({'phase': 2, 'reponame': 'my-devel-rep',
+                          'href': 'common/trunk', 'rev': 137},
+                         svn_prop.get_link(externals[4], repo_list,
+                                           self.req_href, self.ext_map))
+        self.assertEqual('DirRel', externals[5]['dir'])
+        self.assertEqual({'phase': 2, 'reponame': 'my-devel-rep',
+                          'href': 'common/trunk/log', 'rev': None},
+                         svn_prop.get_link(externals[5], repo_list,
+                                           self.req_href, self.ext_map))
+        self.assertEqual('FarDirRel', externals[6]['dir'])
+        self.assertEqual({'phase': 2, 'reponame': 'my-dev-rep',
+                          'href': '/', 'rev': None},
+                         svn_prop.get_link(externals[6], repo_list,
+                                       self.req_href, self.ext_map))
+        self.assertEqual('IllegalDirRel', externals[7]['dir'])
+        self.assertRaises(TracError, svn_prop.get_link, externals[7],
+                               repo_list, self.req_href, self.ext_map)
+        self.assertEqual('RemoteProject', externals[8]['dir'])
+        self.assertEqual({'phase': 1, 'href': '//ourserver/viewvc/svn/'
+                          '?pathrev=6', 'rev': 6},
+                         svn_prop.get_link(externals[8], [self.myrep],
+                                           self.req_href, self.ext_map))
+
+    def test_externals_on_urlless_repository_with_root(self):
+        repo_list = [self.mynourlrep, self.myrep]
+        externals = svn_prop.parse_externals(self.ext_prop_str,
+                                             self.mynourlrep,
+                                             'my-prod/trunk/src')
+        self.assertTrue(isinstance(externals, list))
+        self.assertEqual(9, len(externals))
+        self.assertEqual('OldSyntax', externals[0]['dir'])
+        self.assertEqual({'phase': 2, 'reponame': 'myrep',
+                          'href': 'devel/common/trunk', 'rev': None},
+                         svn_prop.get_link(externals[0], repo_list,
+                                           self.req_href, self.ext_map))
+        self.assertEqual('NewSyntax', externals[1]['dir'])
+        self.assertEqual({'phase': 2, 'reponame': 'myrep',
+                          'href': 'dev', 'rev': 1011},
+                         svn_prop.get_link(externals[1], repo_list,
+                                           self.req_href, self.ext_map))
+        self.assertEqual('SchemeRel', externals[2]['dir'])
+        self.assertEqual({'phase': 2, 'reponame': 'myrep',
+                          'href': 'devel/common/trunk', 'rev': None},
+                         svn_prop.get_link(externals[2], repo_list,
+                                           self.req_href, self.ext_map))
+        self.assertEqual('ServerRel', externals[3]['dir'])
+        self.assertEqual({'phase': 1, 'href': '%s:%s/svn/otherrep/dev' %
+                          (self.req_scheme, self.req_host), 'rev': None},
+                         svn_prop.get_link(externals[3], repo_list,
+                                           self.req_href, self.ext_map))
+        self.assertEqual('RepoRel', externals[4]['dir'])
+        self.assertEqual({'phase': 2, 'reponame': 'my-nourl-rep',
+                          'href': 'common/trunk', 'rev': 137},
+                         svn_prop.get_link(externals[4], repo_list,
+                                           self.req_href, self.ext_map))
+        self.assertEqual('DirRel', externals[5]['dir'])
+        self.assertEqual({'phase': 2, 'reponame': 'my-nourl-rep',
+                          'href': 'common/trunk/log', 'rev': None},
+                         svn_prop.get_link(externals[5], repo_list,
+                                           self.req_href, self.ext_map))
+        self.assertEqual('FarDirRel', externals[6]['dir'])
+        self.assertEqual({'phase': 2, 'reponame': 'myrep',
+                          'href': 'dev', 'rev': None},
+                         svn_prop.get_link(externals[6], repo_list,
+                                       self.req_href, self.ext_map))
+        self.assertEqual('IllegalDirRel', externals[7]['dir'])
+        self.assertRaises(TracError, svn_prop.get_link, externals[7],
+                               repo_list, self.req_href, self.ext_map)
+        self.assertEqual('RemoteProject', externals[8]['dir'])
+        self.assertEqual({'phase': 1, 'href': '//ourserver/viewvc/svn/'
+                          '?pathrev=6', 'rev': 6},
+                         svn_prop.get_link(externals[8], [self.myrep],
+                                           self.req_href, self.ext_map))
+
+    def test_externals_on_urlless_repository_without_root(self):
+        repo_list = [self.mynourlrep, self.otherrep]
+        externals = svn_prop.parse_externals(self.ext_prop_str,
+                                             self.mynourlrep,
+                                             'my-prod/trunk/src')
+        self.assertTrue(isinstance(externals, list))
+        self.assertEqual(9, len(externals))
+        self.assertEqual('OldSyntax', externals[0]['dir'])
+        self.assertEqual({'phase': 1, 'href': 'http://old-server:8000/svn'
+                          '/myrep/devel/common/trunk', 'rev': None},
+                         svn_prop.get_link(externals[0], repo_list,
+                                           self.req_href, self.ext_map))
+        self.assertEqual('NewSyntax', externals[1]['dir'])
+        self.assertEqual({'phase': 1, 'rev': 1011, 
+                          'href': 'http://example.org/svn/myrep/dev'},
+                         svn_prop.get_link(externals[1], repo_list,
+                                           self.req_href, self.ext_map))
+        self.assertEqual('SchemeRel', externals[2]['dir'])
+        self.assertEqual({'phase': 1, 'href': '%s://example.org/svn/myrep/'
+                          'devel/common/trunk' % self.req_scheme, 'rev': None},
+                         svn_prop.get_link(externals[2], repo_list,
+                                           self.req_href, self.ext_map))
+        self.assertEqual('ServerRel', externals[3]['dir'])
+        self.assertEqual({'phase': 2, 'reponame': 'otherrep',
+                          'href': 'dev', 'rev': None},
+                         svn_prop.get_link(externals[3], repo_list,
+                                           self.req_href, self.ext_map))
+        self.assertEqual('RepoRel', externals[4]['dir'])
+        self.assertEqual({'phase': 2, 'reponame': 'my-nourl-rep',
+                          'href': 'common/trunk', 'rev': 137},
+                         svn_prop.get_link(externals[4], repo_list,
+                                           self.req_href, self.ext_map))
+        self.assertEqual('DirRel', externals[5]['dir'])
+        self.assertEqual({'phase': 2, 'reponame': 'my-nourl-rep',
+                          'href': 'common/trunk/log', 'rev': None},
+                         svn_prop.get_link(externals[5], repo_list,
+                                           self.req_href, self.ext_map))
+        self.assertEqual('FarDirRel', externals[6]['dir'])
+        self.assertTrue(svn_prop.get_link(externals[6], repo_list,
+                                       self.req_href, self.ext_map) is None)
+        self.assertEqual('IllegalDirRel', externals[7]['dir'])
+        self.assertRaises(TracError, svn_prop.get_link, externals[7],
+                               repo_list, self.req_href, self.ext_map)
+        self.assertEqual('RemoteProject', externals[8]['dir'])
+        self.assertEqual({'phase': 1, 'href': '//ourserver/viewvc/svn/'
+                          '?pathrev=6', 'rev': 6},
+                         svn_prop.get_link(externals[8], [self.myrep],
+                                           self.req_href, self.ext_map))
+
+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))
+    suite.addTest(unittest.makeSuite(SvnExternalsIntegrationTests))
+    return suite
+
+if __name__ == '__main__':
+    runner = unittest.TextTestRunner()
+    runner.run(suite())

Property changes on: 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: svn_prop.py
===================================================================
--- svn_prop.py	(revision 9482)
+++ 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,274 @@
 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, repo, 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 = {'repo': repo, 'repopath': repopath}
+    ext_dict['rev'] = rev_str = None
+    if '://' in elements[-1]:
+        # Old-style syntax
+        ext_dict['dir'] = elements[0]
+        ext_dict['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['dir'] = elements[-1]
+        ext_dict['url'] = elements[-2]
+        if len(elements) >= 3:
+            rev_str = ''.join(elements[0:(len(elements) == 4 and 2 or 1)])
+        elif '@' in ext_dict['url']:
+            ext_dict['url'], rev_str = ext_dict['url'].split('@')
+            rev_str = '-r%s' % (rev_str)
+    
+    if rev_str:
+        if not rev_str.startswith('-r'):
+            return None
+        if not rev_str[2:].isdigit():
+            return None
+        ext_dict['rev'] = int(rev_str[2:])
+        
+    return ext_dict
 
+def parse_externals(prop_str, repo, 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, repo, repopath)
+                   for line in prop_str.splitlines()])
+
+def externals_generator(prop_str, repo, repopath):
+    """Same as parse_externals, only generator form."""
+    for ext_line in prop_str.splitlines():
+        ext = parse_ext_line(ext_line, repo, 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('../')
+    path_elements = filter(None, dir_rel_path.replace('../', '').split('/'))
+    base_elements = filter(None,
+                           repo_scope.split('/') + base_repo_path.split('/'))
+    if len(base_elements) < up_count:
+        raise TracError(_('Cannot traverse outside '
+                          'repository base directory.'))
+    return '/'.join(['^'] + base_elements[0:-up_count] + path_elements)
+
+def get_repo_relative_path(ext_dict, dest_repo):
+    """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['url']
+    ext_repo = ext_dict['repo']
+    if '://' 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('../'):
+        # Relative to directory
+        url = _convert_dir_rel_to_repo_rel(url, ext_dict['repopath'],
+                                           ext_repo.scope)
+    if url.startswith('^/'):
+        # Relative to repository root
+        repo_url = url[2:]
+        scope = dest_repo.scope.replace('\\', '/').strip('/')
+        scope += '/' if scope else ''
+        if repo_url.startswith(scope):
+            # Target path within current repository
+            # (scoped or not) - generate phase-2 link.
+            return Href(remprefix(repo_url, scope))()
+        scope = scope.strip('/')
+        if repo_url.strip('/') == scope:
+            return Href(remprefix(repo_url, scope))()
+
+def _extract_host_from_url(url):
+    scheme, path = splittype(url or '')
+    host, path = splithost(path or '')
+    return (host or '').lstrip('/')
+
+def get_external_url(ext_dict, dest_repo, 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['url']
+    if '://' 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('/', None) or '').rstrip('/')
+    if url.startswith('//'):
+        # Relative to scheme - partial answer
+        link = url
+        if '://' in repo_url:
+            # Use repo_url scheme to extend the answer
+            link = '%s:%s' % (splittype(repo_url)[0], url)
+        elif req_href and '://' in req_href:
+            # Use request href scheme to extend the answer
+            link = '%s:%s' % (splittype(req_href)[0], url)
+        return link
+    ext_repo = ext_dict['repo'] #rm.get_repository(ext_dict['reponame'])
+    ext_repo_url = (ext_repo.get_path_url('/', None) or '').rstrip('/')
+    if url.startswith('/'):
+        # 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 = '//%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 = '//%s%s' % (_extract_host_from_url(req_href), url)
+            server_url = req_href
+        if '://' in (server_url or ''):
+            return '%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('\\', '/').rstrip('/'))
+    if url.startswith('../'):
+        # Relative to directory
+        url = _convert_dir_rel_to_repo_rel(url, ext_dict['repopath'],
+                                           ext_repo.scope)
+    if url.startswith('^/'):
+        # Relative to repository root
+        return '%s/%s' % (base_repo_url.rstrip('/'),
+                           url[2:].lstrip('/'))
+
+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('svn:externals entry %s doesn\'t contain a '
+                          'space-separated key value pair, skipping.' % dummy)
+            continue
+        key, value = key_value
+        if not '//' in value:
+            # Trac-env-relative link
+            value = remprefix(value.lstrip('/'), 'browser/')
+        ext_map[key.rstrip('/')] = 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('/')
+    while base_url:
+        if base_url in ext_map:
+            path = '/'.join(reversed(path_elements))
+            href = ext_map[base_url].replace('%(rev)s', '%%(rev)s')
+            return href % {'path': path}
+        scheme, url = splittype(base_url)
+        # A schemeless match is also good
+        if scheme and url and url in ext_map:
+            path = '/'.join(reversed(path_elements))
+            href = ext_map[url].replace('%(rev)s', '%%(rev)s')
+            if href.startswith('//'):
+                href = '%s:%s' % (scheme, href)
+            return href % {'path': path}
+        base_url, suffix = posixpath.split(base_url)
+        if base_url and (base_url == '//' or base_url.endswith(':')):
+            break
+        path_elements.append(suffix)
+
+def get_link(ext, repo_list, req_href='', ext_map={}):
+    link_dict = {'phase': 0, 'rev': ext['rev']}
+    for repo in repo_list:
+        # Look for phase-2 link in repository list.
+        link = get_repo_relative_path(ext, repo)
+        if link:
+            link_dict['phase'] = 2
+            link_dict['reponame'] = repo.reponame
+            link_dict['href'] = link
+            return link_dict
+        # Maybe phase-1 link?
+        phase1_link = get_external_url(ext, repo, req_href)
+        if phase1_link:
+            # Try converting to phase-2 using repository as external-map
+            link = format_external_link(
+                        {splittype(repo.get_path_url('/', None) or '')[1]
+                         .rstrip('/'): '%(path)s'}, phase1_link)
+            if link:
+                link_dict['phase'] = 2
+                link_dict['reponame'] = repo.reponame
+                link_dict['href'] = link
+                return link_dict
+            elif 0 == link_dict['phase']:
+                # Keep the first hit as phase-1 link for later use
+                link_dict['phase'] = 1
+                link_dict['href'] = phase1_link
+    if 1 == link_dict['phase']:
+        # Look for conversion with the externals map
+        pre_map_link = link_dict['href']
+        link = format_external_link(ext_map, pre_map_link)
+        if link:
+            link_dict['href'] = link % {'rev': ext['rev']}
+            # If link is intra-Trac, it is phase-2, and might begin with a
+            # repository name and contain '%(rev)s' pattern.
+            if '//' not in link:
+                link = remsuffix(link, '?rev=%(rev)s')
+                reponame = link.split('/')[0]
+                if reponame in [repo.reponame for repo in repo_list]:
+                    link = remprefix(link, reponame).lstrip('/')
+                    link_dict['phase'] = 2
+                    link_dict['reponame'] = reponame
+                    link_dict['href'] = link
+                else:
+                    # No such repository?
+                    link_dict['href'] = pre_map_link
+        return link_dict
+
+
 class SubversionPropertyRenderer(Component):
     implements(IPropertyRenderer)
 
@@ -45,73 +312,48 @@
     
     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:
-                continue
-            localpath, rev, url = elements[0], '', elements[-1]
-            if localpath.startswith('#'):
-                externals.append((external, None, None, None, None))
-                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}))
+            parse_externals_map_ini(self._externals_map,
+                                    self.config.options('svn:externals'),
+                                    self.log)
+
+        reponame = context.resource.parent.id
+        src_repo = rm.get_repository(reponame)
+        repopath = context.resource.id.replace('\\', '/').strip('/')
+        repolist = [src_repo] + [repo for repo in rm.get_real_repositories()
+                                               if repo.reponame != reponame]
+        req_href = context.req.abs_href('/')
+        trs = []
+        for ext in externals_generator(prop, src_repo, repopath):
+            link = get_link(ext, repolist, req_href, self._externals_map)
+            if link:
+                if 2 == link['phase']:
+                    # We have intra-Trac link
+                    href = Href('/browser')(link['reponame'], link['href'])
+                    if ext['rev']:
+                        href = '%s?rev=%d' % (href, ext['rev'])
+                elif 1 == link['phase']:
+                    # The link is external to this Trac
+                    href = link['href']
+                tr = tag.tr(tag.td(tag.a(ext['dir'], href=href,
+                                         title=_('Jump to external'))),
+                            tag.td(tag.a(ext['url'], href=href)),
+                            tag.td(tag.a(ext['rev'] or '', href=href)))
             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(ext['dir'], title=
+                                         _('No matching external')),
+                            tag.td(ext['url']),
+                            tag.td(ext['rev'] or '')))
+            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'),

