Index: trac/versioncontrol/api.py
===================================================================
--- trac/versioncontrol/api.py	(revision 2308)
+++ trac/versioncontrol/api.py	(working copy)
@@ -39,6 +39,12 @@
         """
         raise NotImplementedError
 
+    def has_node(self, path, rev):
+        """
+        Tell if there's a node at the specified (path,rev) combination.
+        """
+        raise NotImplementedError
+    
     def get_node(self, path, rev=None):
         """
         Retrieve a Node (directory or file) from the repository at the
@@ -149,6 +155,7 @@
         node (if the underlying version control system supports that), which
         will be indicated by the first element of the tuple (i.e. the path)
         changing.
+        Starts with an entry for the current revision.
         """
         raise NotImplementedError
 
Index: trac/versioncontrol/tests/svn_fs.py
===================================================================
--- trac/versioncontrol/tests/svn_fs.py	(revision 2308)
+++ trac/versioncontrol/tests/svn_fs.py	(working copy)
@@ -109,6 +109,10 @@
         # ...
         self.assertEqual(None, self.repos.next_rev(12))
 
+    def test_has_node(self):
+        self.assertEqual(False, self.repos.has_node('/trunk/dir1', 3))
+        self.assertEqual(True, self.repos.has_node('/trunk/dir1', 4))
+        
     def test_get_node(self):
         node = self.repos.get_node('/trunk')
         self.assertEqual('trunk', node.name)
@@ -278,9 +282,217 @@
         self.assertRaises(StopIteration, changes.next)
 
 
+class ScopedSubversionRepositoryTestCase(unittest.TestCase):
+
+    def setUp(self):
+        self.repos = SubversionRepository(REPOS_PATH + '/trunk', None,
+                                          logger_factory('test'))
+
+    def tearDown(self):
+        self.repos = None
+
+    def test_repos_normalize_path(self):
+        self.assertEqual('/', self.repos.normalize_path('/'))
+        self.assertEqual('/', self.repos.normalize_path(''))
+        self.assertEqual('/', self.repos.normalize_path(None))
+        self.assertEqual('dir1', self.repos.normalize_path('dir1'))
+        self.assertEqual('dir1', self.repos.normalize_path('/dir1'))
+        self.assertEqual('dir1', self.repos.normalize_path('dir1/'))
+        self.assertEqual('dir1', self.repos.normalize_path('/dir1/'))
+
+    def test_repos_normalize_rev(self):
+        self.assertEqual(6, self.repos.normalize_rev('latest'))
+        self.assertEqual(6, self.repos.normalize_rev('head'))
+        self.assertEqual(6, self.repos.normalize_rev(''))
+        self.assertEqual(6, self.repos.normalize_rev(None))
+        self.assertEqual(5, self.repos.normalize_rev('5'))
+        self.assertEqual(5, self.repos.normalize_rev(5))
+
+    def test_rev_navigation(self):
+        self.assertEqual(1, self.repos.oldest_rev)
+        self.assertEqual(None, self.repos.previous_rev(0))
+        self.assertEqual(1, self.repos.previous_rev(2))
+        self.assertEqual(6, self.repos.youngest_rev)
+        self.assertEqual(2, self.repos.next_rev(1))
+        self.assertEqual(3, self.repos.next_rev(2))
+        # ...
+        self.assertEqual(None, self.repos.next_rev(6))
+
+    def test_has_node(self):
+        self.assertEqual(False, self.repos.has_node('/dir1', 3))
+        self.assertEqual(True, self.repos.has_node('/dir1', 4))
+
+    def test_get_node(self):
+        node = self.repos.get_node('/dir1')
+        self.assertEqual('dir1', node.name)
+        self.assertEqual('/dir1', node.path)
+        self.assertEqual(Node.DIRECTORY, node.kind)
+        self.assertEqual(5, node.rev)
+        self.assertEqual(1112372739, node.last_modified)
+        node = self.repos.get_node('/README.txt')
+        self.assertEqual('README.txt', node.name)
+        self.assertEqual('/README.txt', node.path)
+        self.assertEqual(Node.FILE, node.kind)
+        self.assertEqual(3, node.rev)
+        self.assertEqual(1112361898, node.last_modified)
+
+    def test_get_node_specific_rev(self):
+        node = self.repos.get_node('/dir1', 4)
+        self.assertEqual('dir1', node.name)
+        self.assertEqual('/dir1', node.path)
+        self.assertEqual(Node.DIRECTORY, node.kind)
+        self.assertEqual(4, node.rev)
+        self.assertEqual(1112370155, node.last_modified)
+        node = self.repos.get_node('/README.txt', 2)
+        self.assertEqual('README.txt', node.name)
+        self.assertEqual('/README.txt', node.path)
+        self.assertEqual(Node.FILE, node.kind)
+        self.assertEqual(2, node.rev)
+        self.assertEqual(1112361138, node.last_modified)
+
+    def test_get_dir_entries(self):
+        node = self.repos.get_node('/')
+        entries = node.get_entries()
+        self.assertEqual('README2.txt', entries.next().name)
+        self.assertEqual('dir1', entries.next().name)
+        self.assertEqual('README.txt', entries.next().name)
+        self.assertRaises(StopIteration, entries.next)
+
+    def test_get_file_entries(self):
+        node = self.repos.get_node('/README.txt')
+        entries = node.get_entries()
+        self.assertRaises(StopIteration, entries.next)
+
+    def test_get_dir_content(self):
+        node = self.repos.get_node('/dir1')
+        self.assertEqual(None, node.content_length)
+        self.assertEqual(None, node.content_type)
+        self.assertEqual(None, node.get_content())
+
+    def test_get_file_content(self):
+        node = self.repos.get_node('/README.txt')
+        self.assertEqual(8, node.content_length)
+        self.assertEqual('text/plain', node.content_type)
+        self.assertEqual('A test.\n', node.get_content().read())
+
+    def test_get_dir_properties(self):
+        f = self.repos.get_node('/dir1')
+        props = f.get_properties()
+        self.assertEqual(0, len(props))
+
+    def test_get_file_properties(self):
+        f = self.repos.get_node('/README.txt')
+        props = f.get_properties()
+        self.assertEqual('native', props['svn:eol-style'])
+        self.assertEqual('text/plain', props['svn:mime-type'])
+
+    # Revision Log / node history 
+
+    def test_get_node_history(self):
+        node = self.repos.get_node('/README2.txt')
+        history = node.get_history()
+        self.assertEqual(('README2.txt', 6, 'copy'), history.next())
+        self.assertEqual(('README.txt', 3, 'edit'), history.next())
+        self.assertEqual(('README.txt', 2, 'add'), history.next())
+        self.assertRaises(StopIteration, history.next)
+
+    def test_get_node_history_follow_copy(self):
+        node = self.repos.get_node('dir1/dir3', )
+        history = node.get_history()
+        self.assertEqual(('dir1/dir3', 5, 'copy'), history.next())
+        self.assertEqual(('dir3', 4, 'add'), history.next())
+        self.assertRaises(StopIteration, history.next)
+
+    # Revision Log / path history 
+
+    def test_get_path_history(self):
+        history = self.repos.get_path_history('dir3', None)
+        self.assertEqual(('dir3', 5, 'delete'), history.next())
+        self.assertEqual(('dir3', 4, 'add'), history.next())
+        self.assertRaises(StopIteration, history.next)
+
+    def test_get_path_history_copied_file(self):
+        history = self.repos.get_path_history('README2.txt', None)
+        self.assertEqual(('README2.txt', 6, 'copy'), history.next())
+        self.assertEqual(('README.txt', 3, 'unknown'), history.next())
+        self.assertRaises(StopIteration, history.next)
+        
+    def test_get_path_history_copied_dir(self):
+        history = self.repos.get_path_history('dir1/dir3', None)
+        self.assertEqual(('dir1/dir3', 5, 'copy'), history.next())
+        self.assertEqual(('dir3', 4, 'unknown'), history.next())
+        self.assertRaises(StopIteration, history.next)
+
+    def test_changeset_repos_creation(self):
+        chgset = self.repos.get_changeset(0)
+        self.assertEqual(0, chgset.rev)
+        self.assertEqual(None, chgset.message)
+        self.assertEqual(None, chgset.author)
+        self.assertEqual(1112349461, chgset.date)
+        self.assertRaises(StopIteration, chgset.get_changes().next)
+
+    def test_changeset_added_dirs(self):
+        chgset = self.repos.get_changeset(4)
+        self.assertEqual(4, chgset.rev)
+        self.assertEqual('More directories.', chgset.message)
+        self.assertEqual('john', chgset.author)
+        self.assertEqual(1112370155, chgset.date)
+
+        changes = chgset.get_changes()
+        self.assertEqual(('dir1', Node.DIRECTORY, 'add', None, -1),
+                         changes.next())
+        self.assertEqual(('dir2', Node.DIRECTORY, 'add', None, -1),
+                         changes.next())
+        self.assertEqual(('dir3', Node.DIRECTORY, 'add', None, -1),
+                         changes.next())
+        self.assertRaises(StopIteration, changes.next)
+
+    def test_changeset_file_edit(self):
+        chgset = self.repos.get_changeset(3)
+        self.assertEqual(3, chgset.rev)
+        self.assertEqual('Fixed README.\n', chgset.message)
+        self.assertEqual('kate', chgset.author)
+        self.assertEqual(1112361898, chgset.date)
+
+        changes = chgset.get_changes()
+        self.assertEqual(('README.txt', Node.FILE, Changeset.EDIT,
+                          'README.txt', 2), changes.next())
+        self.assertRaises(StopIteration, changes.next)
+
+    def test_changeset_dir_moves(self):
+        chgset = self.repos.get_changeset(5)
+        self.assertEqual(5, chgset.rev)
+        self.assertEqual('Moved directories.', chgset.message)
+        self.assertEqual('kate', chgset.author)
+        self.assertEqual(1112372739, chgset.date)
+
+        changes = chgset.get_changes()
+        self.assertEqual(('dir1/dir2', Node.DIRECTORY, Changeset.MOVE,
+                          'dir2', 4), changes.next())
+        self.assertEqual(('dir1/dir3', Node.DIRECTORY, Changeset.MOVE,
+                          'dir3', 4), changes.next())
+        self.assertRaises(StopIteration, changes.next)
+
+    def test_changeset_file_copy(self):
+        chgset = self.repos.get_changeset(6)
+        self.assertEqual(6, chgset.rev)
+        self.assertEqual('More things to read', chgset.message)
+        self.assertEqual('john', chgset.author)
+        self.assertEqual(1112381806, chgset.date)
+
+        changes = chgset.get_changes()
+        self.assertEqual(('README2.txt', Node.FILE, Changeset.COPY,
+                          'README.txt', 3), changes.next())
+        self.assertRaises(StopIteration, changes.next)
+
+
 def suite():
-    return unittest.makeSuite(SubversionRepositoryTestCase, 'test',
-                              suiteClass=SubversionRepositoryTestSetup)
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(SubversionRepositoryTestCase, 'test',
+                                     suiteClass=SubversionRepositoryTestSetup))
+    suite.addTest(unittest.makeSuite(ScopedSubversionRepositoryTestCase, 'test',
+                                     suiteClass=SubversionRepositoryTestSetup))
+    return suite
 
 if __name__ == '__main__':
     runner = unittest.TextTestRunner()
Index: trac/versioncontrol/svn_fs.py
===================================================================
--- trac/versioncontrol/svn_fs.py	(revision 2308)
+++ trac/versioncontrol/svn_fs.py	(working copy)
@@ -54,6 +54,19 @@
         yield item
 
 
+def _normalize_path(path):
+    """Remove leading "/", except for the root"""
+    return path and path.strip('/') or '/'
+
+def _scoped_path(scope, fullpath):
+    """Remove the leading scope from repository paths"""
+    if fullpath:
+        if scope == '/':
+            return _normalize_path(fullpath)
+        elif fullpath.startswith(scope.rstrip('/')):
+            return fullpath[len(scope):] or '/'
+
+
 def _mark_weakpool_invalid(weakpool):
     if weakpool():
         weakpool()._mark_invalid()
@@ -207,8 +220,15 @@
     def __del__(self):
         self.close()
 
+    def has_node(self, path, rev, pool=None):
+        if not pool:
+            pool = self.pool
+        rev_root = fs.revision_root(self.fs_ptr, rev, pool())
+        node_type = fs.check_path(rev_root, self.scope + path, pool())
+        return node_type in _kindmap
+
     def normalize_path(self, path):
-        return (not path or path == '/') and '/' or path.strip('/')
+        return _normalize_path(path)
 
     def normalize_rev(self, rev):
         try:
@@ -298,18 +318,16 @@
         subpool = Pool(self.pool)
         while rev:
             subpool.clear()
-            rev_root = fs.revision_root(self.fs_ptr, rev, subpool())
-            node_type = fs.check_path(rev_root, path, subpool())
-            if node_type in _kindmap: # then path exists at that rev
+            if self.has_node(path, rev, subpool):
                 if expect_deletion:
                     # it was missing, now it's there again:
                     #  rev+1 must be a delete
                     yield path, rev+1, Changeset.DELETE
                 newer = None # 'newer' is the previously seen history tuple
                 older = None # 'older' is the currently examined history tuple
-                for p, r in _get_history(path, self.authz, self.fs_ptr,
-                                         subpool, 0, rev, limit):
-                    older = (self.normalize_path(p), r, Changeset.ADD)
+                for p, r in _get_history(self.scope + path, self.authz,
+                                         self.fs_ptr, subpool, 0, rev, limit):
+                    older = (_scoped_path(self.scope, p), r, Changeset.ADD)
                     rev = self.previous_rev(r)
                     if newer:
                         if older[0] == path:
@@ -382,8 +400,9 @@
         pool = Pool(self.pool)
         for path, rev in _get_history(self.scoped_path, self.authz, self.fs_ptr,
                                       pool, 0, self._requested_rev, limit):
-            if rev > 0 and path.startswith(self.scope):
-                older = (path[len(self.scope):], rev, Changeset.ADD)
+            scoped_path = _scoped_path(self.scope, path)
+            if rev > 0 and scoped_path:
+                older = (scoped_path, rev, Changeset.ADD)
                 if newer:
                     change = newer[0] == older[0] and Changeset.EDIT or \
                              Changeset.COPY
@@ -448,12 +467,7 @@
                 continue
             if not path.startswith(self.scope[1:]):
                 continue
-            base_path = None
-            if change.base_path:
-                if change.base_path.startswith(self.scope):
-                    base_path = change.base_path[len(self.scope):]
-                else:
-                    base_path = None
+            base_path = _scoped_path(self.scope, change.base_path)
             action = ''
             if not change.path:
                 action = Changeset.DELETE
Index: trac/versioncontrol/cache.py
===================================================================
--- trac/versioncontrol/cache.py	(revision 2308)
+++ trac/versioncontrol/cache.py	(working copy)
@@ -84,6 +84,9 @@
     def get_node(self, path, rev=None):
         return self.repos.get_node(path, rev)
 
+    def has_node(self, path, rev):
+        return self.repos.has_node(path, rev)
+
     def get_oldest_rev(self):
         return self.repos.oldest_rev
 
Index: trac/versioncontrol/web_ui/log.py
===================================================================
--- trac/versioncontrol/web_ui/log.py	(revision 2308)
+++ trac/versioncontrol/web_ui/log.py	(working copy)
@@ -54,7 +54,7 @@
         import re
         match = re.match(r'/log(?:(/.*)|$)', req.path_info)
         if match:
-            req.args['path'] = match.group(1)
+            req.args['path'] = match.group(1) or '/'
             return 1
 
     def process_request(self, req):

