← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/git-lookup into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/git-lookup into lp:launchpad with lp:~cjwatson/launchpad/git-defaults as a prerequisite.

Commit message:
Add support for looking up Git repositories by path.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1032731 in Launchpad itself: "Support for Launchpad-hosted Git repositories"
  https://bugs.launchpad.net/launchpad/+bug/1032731

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/git-lookup/+merge/250628

Add support for looking up Git repositories by path.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/git-lookup into lp:launchpad.
=== modified file 'lib/lp/code/configure.zcml'
--- lib/lp/code/configure.zcml	2015-02-23 14:47:40 +0000
+++ lib/lp/code/configure.zcml	2015-02-23 14:47:40 +0000
@@ -865,6 +865,22 @@
   <adapter factory="lp.code.model.defaultgit.OwnerProjectDefaultGitRepository" />
   <adapter factory="lp.code.model.defaultgit.OwnerPackageDefaultGitRepository" />
 
+  <class class="lp.code.model.gitlookup.GitLookup">
+    <allow interface="lp.code.interfaces.gitlookup.IGitLookup" />
+  </class>
+  <securedutility
+      class="lp.code.model.gitlookup.GitLookup"
+      provides="lp.code.interfaces.gitlookup.IGitLookup">
+    <allow interface="lp.code.interfaces.gitlookup.IGitLookup" />
+  </securedutility>
+  <securedutility
+      class="lp.code.model.gitlookup.DefaultGitTraverser"
+      provides="lp.code.interfaces.gitlookup.IDefaultGitTraverser">
+    <allow interface="lp.code.interfaces.gitlookup.IDefaultGitTraverser" />
+  </securedutility>
+  <adapter factory="lp.code.model.gitlookup.DistributionGitTraversable" />
+  <adapter factory="lp.code.model.gitlookup.PersonGitTraversable" />
+
   <lp:help-folder folder="help" name="+help-code" />
 
   <!-- Diffs -->

=== added file 'lib/lp/code/interfaces/gitlookup.py'
--- lib/lp/code/interfaces/gitlookup.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/interfaces/gitlookup.py	2015-02-23 14:47:40 +0000
@@ -0,0 +1,131 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Utility for looking up Git repositories by name."""
+
+__metaclass__ = type
+__all__ = [
+    'IDefaultGitTraversable',
+    'IDefaultGitTraverser',
+    'IGitLookup',
+    ]
+
+from zope.interface import Interface
+
+
+class IDefaultGitTraversable(Interface):
+    """A thing that can be traversed to find a thing with a default Git
+    repository."""
+
+    def traverse(name, segments):
+        """Return the object beneath this one that matches 'name'.
+
+        :param name: The name of the object being traversed to.
+        :param segments: Remaining path segments.
+        :return: An `IDefaultGitTraversable` object if traversing should
+            continue; an `ICanHasDefaultGitRepository` object otherwise.
+        """
+
+
+class IDefaultGitTraverser(Interface):
+    """Utility for traversing to an object that can have a default Git
+    repository."""
+
+    def traverse(path):
+        """Traverse to the object referred to by 'path'.
+
+        :raises InvalidNamespace: If the path cannot be parsed as a
+            namespace.
+        :raises InvalidProductName: If the project component of the path is
+            not a valid name.
+        :raises NoSuchPerson: If the first segment of the path begins with a
+            '~', but we can't find a person matching the remainder.
+        :raises NoSuchProduct: If we can't find a project that matches the
+            project component of the path.
+        :raises NoSuchSourcePackageName: If the source package referred to
+            does not exist.
+
+        :return: One of
+            * `IProduct`
+            * `IDistributionSourcePackage`
+            * `IPersonProduct`
+            * `IPersonDistributionSourcePackage`
+        """
+
+
+class IGitLookup(Interface):
+    """Utility for looking up a Git repository by name."""
+
+    def get(repository_id, default=None):
+        """Return the repository with the given id.
+
+        Return the default value if there is no such repository.
+        """
+
+    def getByUniqueName(unique_name):
+        """Find a repository by its unique name.
+
+        Unique names have one of the following forms:
+            ~OWNER/PROJECT/+git/NAME
+            ~OWNER/DISTRO/+source/SOURCE/+git/NAME
+            ~OWNER/+git/NAME
+
+        :return: An `IGitRepository`, or None.
+        """
+
+    def uriToHostingPath(uri):
+        """Return the path for the URI, if the URI is on codehosting.
+
+        This does not ensure that the path is valid.
+
+        :param uri: An instance of lazr.uri.URI
+        :return: The path if possible; None if the URI is not a valid
+            codehosting URI.
+        """
+
+    def getByUrl(url):
+        """Find a repository by URL.
+
+        Either from the URL on git.launchpad.net (various schemes) or the
+        lp: URL (which relies on client-side configuration).
+        """
+
+    def getByPath(path):
+        """Find a repository by its path.
+
+        Any of these forms may be used, with or without a leading slash:
+            Unique names:
+                ~OWNER/PROJECT/+git/NAME
+                ~OWNER/DISTRO/+source/SOURCE/+git/NAME
+                ~OWNER/+git/NAME
+            Owner-target default aliases:
+                ~OWNER/PROJECT
+                ~OWNER/DISTRO/+source/SOURCE
+            Official aliases:
+                PROJECT
+                DISTRO/+source/SOURCE
+
+        :raises InvalidNamespace: If the path looks like a unique repository
+            name but doesn't have enough segments to be a unique name.
+        :raises InvalidProductName: If the given project in a project
+            shortcut is an invalid name for a project.
+
+        :raises NoSuchGitRepository: If we can't find a repository that
+            matches the repository component of the path.
+        :raises NoSuchPerson: If we can't find a person who matches the
+            person component of the path.
+        :raises NoSuchProduct: If we can't find a project that matches the
+            project component of the path.
+        :raises NoSuchSourcePackageName: If the source package referred to
+            does not exist.
+
+        :raises NoDefaultGitRepository: If the path refers to an existing
+            thing that's not a Git repository and has no default repository
+            associated with it.  For example, a product without a default
+            repository.
+        :raises CannotHaveDefaultGitRepository: If the path refers to an
+            existing thing that cannot have a default Git repository
+            associated with it.  For example, a distribution.
+
+        :return: An `IGitRepository`.
+        """

=== added file 'lib/lp/code/model/gitlookup.py'
--- lib/lp/code/model/gitlookup.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/model/gitlookup.py	2015-02-23 14:47:40 +0000
@@ -0,0 +1,275 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Database implementation of the Git repository lookup utility."""
+
+__metaclass__ = type
+# This module doesn't export anything. If you want to look up Git
+# repositories by name, then get the IGitLookup utility.
+__all__ = []
+
+from lazr.uri import (
+    InvalidURIError,
+    URI,
+    )
+from zope.component import (
+    adapts,
+    getUtility,
+    queryMultiAdapter,
+    )
+from zope.interface import implements
+
+from lp.app.validators.name import valid_name
+from lp.code.errors import (
+    CannotHaveDefaultGitRepository,
+    InvalidNamespace,
+    NoDefaultGitRepository,
+    NoSuchGitRepository,
+    )
+from lp.code.interfaces.defaultgit import get_default_git_repository
+from lp.code.interfaces.gitlookup import (
+    IDefaultGitTraversable,
+    IDefaultGitTraverser,
+    IGitLookup,
+    )
+from lp.code.interfaces.gitnamespace import IGitNamespaceSet
+from lp.code.model.gitrepository import GitRepository
+from lp.registry.errors import NoSuchSourcePackageName
+from lp.registry.interfaces.distribution import IDistribution
+from lp.registry.interfaces.person import (
+    IPerson,
+    IPersonSet,
+    NoSuchPerson,
+    )
+from lp.registry.interfaces.persondistributionsourcepackage import (
+    IPersonDistributionSourcePackageFactory,
+    )
+from lp.registry.interfaces.personproduct import IPersonProductFactory
+from lp.registry.interfaces.pillar import IPillarNameSet
+from lp.registry.interfaces.product import (
+    InvalidProductName,
+    NoSuchProduct,
+    )
+from lp.services.config import config
+from lp.services.database.interfaces import IStore
+
+
+def adapt(provided, interface):
+    """Adapt 'obj' to 'interface', using multi-adapters if necessary."""
+    required = interface(provided, None)
+    if required is not None:
+        return required
+    try:
+        return queryMultiAdapter(provided, interface)
+    except TypeError:
+        return None
+
+
+class RootGitTraversable:
+    """Root traversable for default Git repository objects.
+
+    Corresponds to '/' in the path.  From here, you can traverse to a
+    person, a distribution, or a project.
+    """
+
+    implements(IDefaultGitTraversable)
+
+    def traverse(self, name, segments):
+        """See `IDefaultGitTraversable`.
+
+        :raise InvalidProductName: If 'name' is not a valid name.
+        :raise NoSuchPerson: If 'name' begins with a '~', but the remainder
+            doesn't match an existing person.
+        :raise NoSuchProduct: If 'name' doesn't match an existing pillar.
+        :return: `IPerson` or `IPillar`.
+        """
+        if name.startswith("~"):
+            person_name = name[1:]
+            person = getUtility(IPersonSet).getByName(person_name)
+            if person is None:
+                raise NoSuchPerson(person_name)
+            return person
+        if not valid_name(name):
+            raise InvalidProductName(name)
+        pillar = getUtility(IPillarNameSet).getByName(name)
+        if pillar is None:
+            # Actually, the pillar is no such *anything*.
+            raise NoSuchProduct(name)
+        return pillar
+
+
+class _BaseGitTraversable:
+    """Base class for traversable implementations.
+
+    This just defines a very simple constructor.
+    """
+
+    def __init__(self, context):
+        self.context = context
+
+
+class DistributionGitTraversable(_BaseGitTraversable):
+    """Default Git repository traversable for distributions.
+
+    From here, you can traverse to a distribution source package.
+    """
+
+    adapts(IDistribution)
+    implements(IDefaultGitTraversable)
+
+    def traverse(self, name, segments):
+        """See `IDefaultGitTraversable`.
+
+        :raise InvalidNamespace: If 'name' is not '+source' or there are no
+            further segments.
+        :raise NoSuchSourcePackageName: If the segment after '+source'
+            doesn't match an existing source package name.
+        :return: `IDistributionSourcePackage`.
+        """
+        if name != "+source" or not segments:
+            raise InvalidNamespace(name)
+        spn_name = segments.pop(0)
+        distro_source_package = self.context.getSourcePackage(spn_name)
+        if distro_source_package is None:
+            raise NoSuchSourcePackageName(spn_name)
+        return distro_source_package
+
+
+class PersonGitTraversable(_BaseGitTraversable):
+    """Default Git repository traversable for people.
+
+    From here, you can traverse to a person-distribution-source-package or a
+    person-project.
+    """
+
+    adapts(IPerson)
+    implements(IDefaultGitTraversable)
+
+    def traverse(self, name, segments):
+        """See `IDefaultGitTraversable`.
+
+        :raise InvalidNamespace: If 'name' matches an existing distribution,
+            and the next segment is not '+source' or there are no further
+            segments.
+        :raise InvalidProductName: If 'name' is not a valid name.
+        :raise NoSuchProduct: If 'name' doesn't match an existing pillar.
+        :raise NoSuchSourcePackageName: If 'name' matches an existing
+            distribution, and the segment after '+source' doesn't match an
+            existing source package name.
+        :return: `IPersonProduct` or `IPersonDistributionSourcePackage`.
+        """
+        if not valid_name(name):
+            raise InvalidProductName(name)
+        pillar = getUtility(IPillarNameSet).getByName(name)
+        if pillar is None:
+            # Actually, the pillar is no such *anything*.
+            raise NoSuchProduct(name)
+        # XXX cjwatson 2015-02-23: This would be neater if
+        # IPersonDistribution existed.
+        if IDistribution.providedBy(pillar):
+            if len(segments) < 2:
+                raise InvalidNamespace(name)
+            segments.pop(0)
+            spn_name = segments.pop(0)
+            distro_source_package = pillar.getSourcePackage(spn_name)
+            if distro_source_package is None:
+                raise NoSuchSourcePackageName(spn_name)
+            return getUtility(IPersonDistributionSourcePackageFactory).create(
+                self.context, distro_source_package)
+        else:
+            return getUtility(IPersonProductFactory).create(
+                self.context, pillar)
+
+
+class DefaultGitTraverser:
+    """Utility for traversing to objects that can have default repositories."""
+
+    implements(IDefaultGitTraverser)
+
+    def traverse(self, path):
+        """See `IDefaultGitTraverser`."""
+        segments = path.split("/")
+        traversable = RootGitTraversable()
+        while segments:
+            name = segments.pop(0)
+            context = traversable.traverse(name, segments)
+            traversable = adapt(context, IDefaultGitTraversable)
+            if traversable is None:
+                break
+        if segments:
+            raise InvalidNamespace(path)
+        return context
+
+
+class GitLookup:
+    """Utility for looking up Git repositories."""
+
+    implements(IGitLookup)
+
+    def get(self, repository_id, default=None):
+        """See `IGitLookup`."""
+        repository = IStore(GitRepository).get(GitRepository, repository_id)
+        if repository is None:
+            return default
+        return repository
+
+    @staticmethod
+    def uriToHostingPath(uri):
+        """See `IGitLookup`."""
+        schemes = ('git', 'git+ssh', 'https', 'ssh')
+        codehosting_host = URI(config.codehosting.git_anon_root).host
+        if ((uri.scheme in schemes and uri.host == codehosting_host) or
+            (uri.scheme == "lp" and uri.host is None)):
+            return uri.path.lstrip("/")
+        else:
+            return None
+
+    def getByUrl(self, url):
+        """See `IGitLookup`."""
+        if url is None:
+            return None
+        url = url.rstrip("/")
+        try:
+            uri = URI(url)
+        except InvalidURIError:
+            return None
+
+        path = self.uriToHostingPath(uri)
+        if path is None:
+            return None
+        try:
+            return self.getByPath(path)
+        except (
+            CannotHaveDefaultGitRepository, InvalidNamespace,
+            InvalidProductName, NoDefaultGitRepository, NoSuchGitRepository,
+            NoSuchPerson, NoSuchProduct, NoSuchSourcePackageName):
+            return None
+
+    def getByUniqueName(self, unique_name):
+        """See `IGitLookup`."""
+        try:
+            if unique_name.startswith("~"):
+                path = unique_name.lstrip("~")
+                namespace_set = getUtility(IGitNamespaceSet)
+                segments = iter(path.split("/"))
+                repository = namespace_set.traverse(segments)
+                if list(segments):
+                    raise InvalidNamespace(path)
+                return repository
+        except InvalidNamespace:
+            pass
+        return None
+
+    def getByPath(self, path):
+        """See `IGitLookup`."""
+        # Try parsing as a unique name.
+        repository = self.getByUniqueName(path)
+        if repository is not None:
+            return repository
+
+        # Try parsing as a shortcut.
+        object_with_git_repository_default = getUtility(
+            IDefaultGitTraverser).traverse(path)
+        default = get_default_git_repository(
+            object_with_git_repository_default)
+        return default.repository

=== modified file 'lib/lp/code/model/gitrepository.py'
--- lib/lp/code/model/gitrepository.py	2015-02-19 23:57:34 +0000
+++ lib/lp/code/model/gitrepository.py	2015-02-23 14:47:40 +0000
@@ -24,21 +24,30 @@
     Reference,
     Unicode,
     )
-from zope.component import getUtility
+from zope.component import (
+    getAdapter,
+    getUtility,
+    )
 from zope.interface import implements
+from zope.security.proxy import removeSecurityProxy
 
 from lp.app.enums import (
     InformationType,
     PRIVATE_INFORMATION_TYPES,
     PUBLIC_INFORMATION_TYPES,
     )
+from lp.app.errors import NotFoundError
 from lp.app.interfaces.informationtype import IInformationType
 from lp.app.interfaces.launchpad import IPrivacy
+from lp.app.interfaces.security import IAuthorization
 from lp.app.interfaces.services import IService
 from lp.code.errors import (
     GitDefaultConflict,
     GitTargetError,
+    InvalidGitRepositoryException,
+    InvalidNamespace,
     )
+from lp.code.interfaces.gitlookup import IGitLookup
 from lp.code.interfaces.gitnamespace import (
     get_git_namespace,
     IGitNamespacePolicy,
@@ -59,8 +68,14 @@
     IDistributionSourcePackage,
     )
 from lp.registry.interfaces.person import IPerson
-from lp.registry.interfaces.product import IProduct
-from lp.registry.interfaces.role import IHasOwner
+from lp.registry.interfaces.product import (
+    InvalidProductName,
+    IProduct,
+    )
+from lp.registry.interfaces.role import (
+    IHasOwner,
+    IPersonRoles,
+    )
 from lp.registry.interfaces.sharingjob import (
     IRemoveArtifactSubscriptionsJobSource,
     )
@@ -355,8 +370,18 @@
 
     def getByPath(self, user, path):
         """See `IGitRepositorySet`."""
-        # XXX cjwatson 2015-02-06: Fill this in once IGitLookup is in place.
-        raise NotImplementedError
+        try:
+            repository = getUtility(IGitLookup).getByPath(path)
+        except (InvalidGitRepositoryException, InvalidNamespace,
+                InvalidProductName, NotFoundError):
+            return None
+        authz = getAdapter(
+            removeSecurityProxy(repository), IAuthorization, 'launchpad.View')
+        if ((user is None and authz.checkUnauthenticated()) or
+            (user is not None and authz.checkAuthenticated(
+                IPersonRoles(user)))):
+            return repository
+        return None
 
     def getDefaultRepository(self, target, owner=None):
         """See `IGitRepositorySet`."""

=== added file 'lib/lp/code/model/tests/test_gitlookup.py'
--- lib/lp/code/model/tests/test_gitlookup.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/model/tests/test_gitlookup.py	2015-02-23 14:47:40 +0000
@@ -0,0 +1,338 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for the IGitLookup implementation."""
+
+__metaclass__ = type
+
+from lazr.uri import URI
+from zope.component import getUtility
+
+from lp.code.errors import (
+    CannotHaveDefaultGitRepository,
+    InvalidNamespace,
+    NoDefaultGitRepository,
+    )
+from lp.code.interfaces.gitlookup import (
+    IDefaultGitTraverser,
+    IGitLookup,
+    )
+from lp.registry.errors import NoSuchSourcePackageName
+from lp.registry.interfaces.person import NoSuchPerson
+from lp.registry.interfaces.persondistributionsourcepackage import (
+    IPersonDistributionSourcePackageFactory,
+    )
+from lp.registry.interfaces.personproduct import IPersonProductFactory
+from lp.registry.interfaces.product import (
+    InvalidProductName,
+    NoSuchProduct,
+    )
+from lp.services.config import config
+from lp.testing import (
+    person_logged_in,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import DatabaseFunctionalLayer
+
+
+class TestGetByUniqueName(TestCaseWithFactory):
+    """Tests for `IGitLookup.getByUniqueName`."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestGetByUniqueName, self).setUp()
+        self.lookup = getUtility(IGitLookup)
+
+    def test_not_found(self):
+        unused_name = self.factory.getUniqueString()
+        self.assertIsNone(self.lookup.getByUniqueName(unused_name))
+
+    def test_project(self):
+        repository = self.factory.makeGitRepository()
+        self.assertEqual(
+            repository, self.lookup.getByUniqueName(repository.unique_name))
+
+    def test_package(self):
+        dsp = self.factory.makeDistributionSourcePackage()
+        repository = self.factory.makeGitRepository(target=dsp)
+        self.assertEqual(
+            repository, self.lookup.getByUniqueName(repository.unique_name))
+
+    def test_personal(self):
+        owner = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(owner=owner, target=owner)
+        self.assertEqual(
+            repository, self.lookup.getByUniqueName(repository.unique_name))
+
+
+class TestGetByPath(TestCaseWithFactory):
+    """Test `IGitLookup.getByPath`."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestGetByPath, self).setUp()
+        self.lookup = getUtility(IGitLookup)
+
+    def test_project(self):
+        repository = self.factory.makeGitRepository()
+        self.assertEqual(
+            repository, self.lookup.getByPath(repository.unique_name))
+
+    def test_project_default(self):
+        repository = self.factory.makeGitRepository()
+        with person_logged_in(repository.target.owner):
+            repository.target.setDefaultGitRepository(repository)
+        self.assertEqual(
+            repository, self.lookup.getByPath(repository.shortened_path))
+
+    def test_package(self):
+        dsp = self.factory.makeDistributionSourcePackage()
+        repository = self.factory.makeGitRepository(target=dsp)
+        self.assertEqual(
+            repository, self.lookup.getByPath(repository.unique_name))
+
+    def test_package_default(self):
+        dsp = self.factory.makeDistributionSourcePackage()
+        repository = self.factory.makeGitRepository(target=dsp)
+        with person_logged_in(repository.target.distribution.owner):
+            repository.target.setDefaultGitRepository(repository)
+        self.assertEqual(
+            repository, self.lookup.getByPath(repository.shortened_path))
+
+    def test_personal(self):
+        owner = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(owner=owner, target=owner)
+        self.assertEqual(
+            repository, self.lookup.getByPath(repository.unique_name))
+
+    def test_cannot_have_default_git_repository(self):
+        # If `getByPath` is given a path to something with no default Git
+        # repository, such as a distribution, it raises
+        # CannotHaveDefaultGitRepository.
+        distro = self.factory.makeDistribution()
+        self.assertRaises(
+            CannotHaveDefaultGitRepository, self.lookup.getByPath, distro.name)
+
+    def test_no_default_git_repository(self):
+        # If `getByPath` is given a path to something that could have a Git
+        # repository but doesn't, it raises NoDefaultGitRepository.
+        project = self.factory.makeProduct()
+        self.assertRaises(
+            NoDefaultGitRepository, self.lookup.getByPath, project.name)
+
+
+class TestGetByUrl(TestCaseWithFactory):
+    """Test `IGitLookup.getByUrl`."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestGetByUrl, self).setUp()
+        self.lookup = getUtility(IGitLookup)
+
+    def makeProjectRepository(self):
+        owner = self.factory.makePerson(name="aa")
+        project = self.factory.makeProduct(name="bb")
+        return self.factory.makeGitRepository(
+            owner=owner, target=project, name=u"cc")
+
+    def test_getByUrl_with_none(self):
+        # getByUrl returns None if given None.
+        self.assertIsNone(self.lookup.getByUrl(None))
+
+    def assertUrlMatches(self, url, repository):
+        self.assertEqual(repository, self.lookup.getByUrl(url))
+
+    def test_getByUrl_with_trailing_slash(self):
+        # Trailing slashes are stripped from the URL prior to searching.
+        repository = self.makeProjectRepository()
+        self.assertUrlMatches(
+            "git://git.launchpad.dev/~aa/bb/+git/cc/", repository)
+
+    def test_getByUrl_with_git(self):
+        # getByUrl recognises LP repositories for git URLs.
+        repository = self.makeProjectRepository()
+        self.assertUrlMatches(
+            "git://git.launchpad.dev/~aa/bb/+git/cc", repository)
+
+    def test_getByUrl_with_git_ssh(self):
+        # getByUrl recognises LP repositories for git+ssh URLs.
+        repository = self.makeProjectRepository()
+        self.assertUrlMatches(
+            "git+ssh://git.launchpad.dev/~aa/bb/+git/cc", repository)
+
+    def test_getByUrl_with_https(self):
+        # getByUrl recognises LP repositories for https URLs.
+        repository = self.makeProjectRepository()
+        self.assertUrlMatches(
+            "https://git.launchpad.dev/~aa/bb/+git/cc";, repository)
+
+    def test_getByUrl_with_ssh(self):
+        # getByUrl recognises LP repositories for ssh URLs.
+        repository = self.makeProjectRepository()
+        self.assertUrlMatches(
+            "ssh://git.launchpad.dev/~aa/bb/+git/cc", repository)
+
+    def test_getByUrl_with_ftp(self):
+        # getByUrl does not recognise LP repositories for ftp URLs.
+        self.makeProjectRepository()
+        self.assertIsNone(
+            self.lookup.getByUrl("ftp://git.launchpad.dev/~aa/bb/+git/cc";))
+
+    def test_getByUrl_with_lp(self):
+        # getByUrl supports lp: URLs.
+        url = "lp:~aa/bb/+git/cc"
+        self.assertIsNone(self.lookup.getByUrl(url))
+        repository = self.makeProjectRepository()
+        self.assertUrlMatches(url, repository)
+
+    def test_getByUrl_with_default(self):
+        # getByUrl honours default repositories when looking up URLs.
+        repository = self.makeProjectRepository()
+        with person_logged_in(repository.target.owner):
+            repository.target.setDefaultGitRepository(repository)
+        self.assertUrlMatches("lp:bb", repository)
+
+    def test_uriToHostingPath(self):
+        # uriToHostingPath only supports our own URLs with certain schemes.
+        uri = URI(config.codehosting.git_anon_root)
+        uri.path = "/~foo/bar/baz"
+        # Test valid schemes.
+        for scheme in ("git", "git+ssh", "https", "ssh"):
+            uri.scheme = scheme
+            self.assertEqual("~foo/bar/baz", self.lookup.uriToHostingPath(uri))
+        # Test an invalid scheme.
+        uri.scheme = "ftp"
+        self.assertIsNone(self.lookup.uriToHostingPath(uri))
+        # Test valid scheme but invalid domain.
+        uri.scheme = 'sftp'
+        uri.host = 'example.com'
+        self.assertIsNone(self.lookup.uriToHostingPath(uri))
+
+
+class TestDefaultGitTraverser(TestCaseWithFactory):
+    """Tests for the default repository traverser."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestDefaultGitTraverser, self).setUp()
+        self.traverser = getUtility(IDefaultGitTraverser)
+
+    def assertTraverses(self, path, result):
+        self.assertEqual(result, self.traverser.traverse(path))
+
+    def test_nonexistent_project(self):
+        # `traverse` raises `NoSuchProduct` when resolving a path of
+        # 'project' if the project doesn't exist.
+        self.assertRaises(NoSuchProduct, self.traverser.traverse, "bb")
+
+    def test_invalid_project(self):
+        # `traverse` raises `InvalidProductName` when resolving a path for a
+        # completely invalid default project repository.
+        self.assertRaises(InvalidProductName, self.traverser.traverse, "b")
+
+    def test_project(self):
+        # `traverse` resolves the name of a project to the project itself.
+        project = self.factory.makeProduct()
+        self.assertTraverses(project.name, project)
+
+    def test_no_such_distribution(self):
+        # `traverse` raises `NoSuchProduct` if the distribution doesn't
+        # exist.  That's because it can't tell the difference between the
+        # name of a project that doesn't exist and the name of a
+        # distribution that doesn't exist.
+        self.assertRaises(
+            NoSuchProduct, self.traverser.traverse, "distro/+source/package")
+
+    def test_missing_sourcepackagename(self):
+        # `traverse` raises `InvalidNamespace` if there are no segments
+        # after '+source'.
+        self.factory.makeDistribution(name="distro")
+        self.assertRaises(
+            InvalidNamespace, self.traverser.traverse, "distro/+source")
+
+    def test_no_such_sourcepackagename(self):
+        # `traverse` raises `NoSuchSourcePackageName` if the package in
+        # distro/+source/package doesn't exist.
+        self.factory.makeDistribution(name="distro")
+        self.assertRaises(
+            NoSuchSourcePackageName, self.traverser.traverse,
+            "distro/+source/nonexistent")
+
+    def test_package(self):
+        # `traverse` resolves 'distro/+source/package' to the distribution
+        # source package.
+        dsp = self.factory.makeDistributionSourcePackage()
+        path = "%s/+source/%s" % (
+            dsp.distribution.name, dsp.sourcepackagename.name)
+        self.assertTraverses(path, dsp)
+
+    def test_nonexistent_person(self):
+        # `traverse` raises `NoSuchPerson` when resolving a path of
+        # '~person/project' if the person doesn't exist.
+        self.assertRaises(NoSuchPerson, self.traverser.traverse, "~person/bb")
+
+    def test_nonexistent_person_project(self):
+        # `traverse` raises `NoSuchProduct` when resolving a path of
+        # '~person/project' if the project doesn't exist.
+        self.factory.makePerson(name="person")
+        self.assertRaises(NoSuchProduct, self.traverser.traverse, "~person/bb")
+
+    def test_invalid_person_project(self):
+        # `traverse` raises `InvalidProductName` when resolving a path for a
+        # person and a completely invalid default project repository.
+        self.factory.makePerson(name="person")
+        self.assertRaises(
+            InvalidProductName, self.traverser.traverse, "~person/b")
+
+    def test_person_project(self):
+        # `traverse` resolves '~person/project' to a PersonProduct.
+        person = self.factory.makePerson()
+        project = self.factory.makeProduct()
+        person_project = getUtility(IPersonProductFactory).create(
+            person, project)
+        self.assertTraverses(
+            "~%s/%s" % (person.name, project.name), person_project)
+
+    def test_no_such_person_distribution(self):
+        # `traverse` raises `NoSuchProduct` when resolving a path of
+        # '~person/distro' if the distribution doesn't exist.  That's
+        # because it can't tell the difference between the name of a project
+        # that doesn't exist and the name of a distribution that doesn't
+        # exist.
+        self.factory.makePerson(name="person")
+        self.assertRaises(
+            NoSuchProduct, self.traverser.traverse,
+            "~person/distro/+source/package")
+
+    def test_missing_person_sourcepackagename(self):
+        # `traverse` raises `InvalidNamespace` if there are no segments
+        # after '+source' in a person-DSP path.
+        self.factory.makePerson(name="person")
+        self.factory.makeDistribution(name="distro")
+        self.assertRaises(
+            InvalidNamespace, self.traverser.traverse,
+            "~person/distro/+source")
+
+    def test_no_such_person_sourcepackagename(self):
+        # `traverse` raises `NoSuchSourcePackageName` if the package in
+        # ~person/distro/+source/package doesn't exist.
+        self.factory.makePerson(name="person")
+        self.factory.makeDistribution(name="distro")
+        self.assertRaises(
+            NoSuchSourcePackageName, self.traverser.traverse,
+            "~person/distro/+source/nonexistent")
+
+    def test_person_package(self):
+        # `traverse` resolves '~person/distro/+source/package' to a
+        # PersonDistributionSourcePackage.
+        person = self.factory.makePerson()
+        dsp = self.factory.makeDistributionSourcePackage()
+        person_dsp = getUtility(
+            IPersonDistributionSourcePackageFactory).create(person, dsp)
+        path = "~%s/%s/+source/%s" % (
+            person.name, dsp.distribution.name, dsp.sourcepackagename.name)
+        self.assertTraverses(path, person_dsp)

=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
--- lib/lp/code/model/tests/test_gitrepository.py	2015-02-23 14:47:40 +0000
+++ lib/lp/code/model/tests/test_gitrepository.py	2015-02-23 14:47:40 +0000
@@ -29,7 +29,10 @@
     IGitNamespacePolicy,
     IGitNamespaceSet,
     )
-from lp.code.interfaces.gitrepository import IGitRepository
+from lp.code.interfaces.gitrepository import (
+    IGitRepository,
+    IGitRepositorySet,
+    )
 from lp.registry.enums import BranchSharingPolicy
 from lp.registry.interfaces.persondistributionsourcepackage import (
     IPersonDistributionSourcePackageFactory,
@@ -486,3 +489,27 @@
             self.assertRaises(
                 GitTargetError, repository.setTarget,
                 target=commercial_project, user=owner)
+
+
+class TestGitRepositorySet(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_provides_IGitRepositorySet(self):
+        # GitRepositorySet instances provide IGitRepositorySet.
+        verifyObject(IGitRepositorySet, getUtility(IGitRepositorySet))
+
+    def test_getByPath(self):
+        # getByPath returns a repository matching the path that it's given.
+        a = self.factory.makeGitRepository()
+        self.factory.makeGitRepository()
+        repository = getUtility(IGitRepositorySet).getByPath(
+            a.owner, a.shortened_path)
+        self.assertEqual(a, repository)
+
+    def test_getByPath_not_found(self):
+        # If a repository cannot be found for a path, then getByPath returns
+        # None.
+        person = self.factory.makePerson()
+        self.assertIsNone(
+            getUtility(IGitRepositorySet).getByPath(person, "nonexistent"))


Follow ups