← Back to team overview

launchpad-reviewers team mailing list archive

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

 

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

Commit message:
Add support for collections of Git repositories.

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-collection/+merge/250646

Add support for collections of Git repositories.

This is mostly a much-reduced version of BranchLookup.  It's also the last of the big core chunks of the Git repository model; after this it should be possible to work in more digestible pieces rather than thousand-line sections of entirely new code.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/git-collection into lp:launchpad.
=== added file 'lib/lp/code/adapters/gitcollection.py'
--- lib/lp/code/adapters/gitcollection.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/adapters/gitcollection.py	2015-02-23 16:14:41 +0000
@@ -0,0 +1,62 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Adapters for different objects to Git repository collections."""
+
+__metaclass__ = type
+__all__ = [
+    'git_collection_for_distribution',
+    'git_collection_for_distro_source_package',
+    'git_collection_for_person',
+    'git_collection_for_person_distro_source_package',
+    'git_collection_for_person_product',
+    'git_collection_for_project',
+    'git_collection_for_project_group',
+    ]
+
+
+from zope.component import getUtility
+
+from lp.code.interfaces.gitcollection import IAllGitRepositories
+
+
+def git_collection_for_project(project):
+    """Adapt a product to a Git repository collection."""
+    return getUtility(IAllGitRepositories).inProject(project)
+
+
+def git_collection_for_project_group(project_group):
+    """Adapt a project group to a Git repository collection."""
+    return getUtility(IAllGitRepositories).inProjectGroup(project_group)
+
+
+def git_collection_for_distribution(distribution):
+    """Adapt a distribution to a Git repository collection."""
+    return getUtility(IAllGitRepositories).inDistribution(distribution)
+
+
+def git_collection_for_distro_source_package(distro_source_package):
+    """Adapt a distro_source_package to a Git repository collection."""
+    return getUtility(IAllGitRepositories).inDistributionSourcePackage(
+        distro_source_package)
+
+
+def git_collection_for_person(person):
+    """Adapt a person to a Git repository collection."""
+    return getUtility(IAllGitRepositories).ownedBy(person)
+
+
+def git_collection_for_person_product(person_product):
+    """Adapt a PersonProduct to a Git repository collection."""
+    collection = getUtility(IAllGitRepositories).ownedBy(person_product.person)
+    collection = collection.inProject(person_product.product)
+    return collection
+
+
+def git_collection_for_person_distro_source_package(person_dsp):
+    """Adapt a PersonDistributionSourcePackage to a Git repository
+    collection."""
+    collection = getUtility(IAllGitRepositories).ownedBy(person_dsp.person)
+    collection = collection.inDistributionSourcePackage(
+        person_dsp.distro_source_package)
+    return collection

=== modified file 'lib/lp/code/configure.zcml'
--- lib/lp/code/configure.zcml	2015-02-23 16:14:41 +0000
+++ lib/lp/code/configure.zcml	2015-02-23 16:14:41 +0000
@@ -858,6 +858,55 @@
     <allow interface="lp.code.interfaces.gitnamespace.IGitNamespaceSet" />
   </securedutility>
 
+  <!-- GitCollection -->
+
+  <class class="lp.code.model.gitcollection.GenericGitCollection">
+    <allow interface="lp.code.interfaces.gitcollection.IGitCollection"/>
+  </class>
+  <class class="lp.code.model.gitcollection.AnonymousGitCollection">
+    <allow interface="lp.code.interfaces.gitcollection.IGitCollection"/>
+  </class>
+  <class class="lp.code.model.gitcollection.VisibleGitCollection">
+    <allow interface="lp.code.interfaces.gitcollection.IGitCollection"/>
+  </class>
+  <adapter
+      for="storm.store.Store"
+      provides="lp.code.interfaces.gitcollection.IGitCollection"
+      factory="lp.code.model.gitcollection.GenericGitCollection"/>
+  <adapter
+      for="lp.registry.interfaces.product.IProduct"
+      provides="lp.code.interfaces.gitcollection.IGitCollection"
+      factory="lp.code.adapters.gitcollection.git_collection_for_project"/>
+  <adapter
+      for="lp.registry.interfaces.projectgroup.IProjectGroup"
+      provides="lp.code.interfaces.gitcollection.IGitCollection"
+      factory="lp.code.adapters.gitcollection.git_collection_for_project_group"/>
+  <adapter
+      for="lp.registry.interfaces.distribution.IDistribution"
+      provides="lp.code.interfaces.gitcollection.IGitCollection"
+      factory="lp.code.adapters.gitcollection.git_collection_for_distribution"/>
+  <adapter
+      for="lp.registry.interfaces.distributionsourcepackage.IDistributionSourcePackage"
+      provides="lp.code.interfaces.gitcollection.IGitCollection"
+      factory="lp.code.adapters.gitcollection.git_collection_for_distro_source_package"/>
+  <adapter
+      for="lp.registry.interfaces.person.IPerson"
+      provides="lp.code.interfaces.gitcollection.IGitCollection"
+      factory="lp.code.adapters.gitcollection.git_collection_for_person"/>
+  <adapter
+      for="lp.registry.interfaces.personproduct.IPersonProduct"
+      provides="lp.code.interfaces.gitcollection.IGitCollection"
+      factory="lp.code.adapters.gitcollection.git_collection_for_person_product"/>
+  <adapter
+      for="lp.registry.interfaces.persondistributionsourcepackage.IPersonDistributionSourcePackage"
+      provides="lp.code.interfaces.gitcollection.IGitCollection"
+      factory="lp.code.adapters.gitcollection.git_collection_for_person_distro_source_package"/>
+  <securedutility
+      class="lp.code.model.gitcollection.GenericGitCollection"
+      provides="lp.code.interfaces.gitcollection.IAllGitRepositories">
+    <allow interface="lp.code.interfaces.gitcollection.IAllGitRepositories"/>
+  </securedutility>
+
   <!-- Default Git repositories -->
 
   <adapter factory="lp.code.model.defaultgit.ProjectDefaultGitRepository" />

=== added file 'lib/lp/code/interfaces/gitcollection.py'
--- lib/lp/code/interfaces/gitcollection.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/interfaces/gitcollection.py	2015-02-23 16:14:41 +0000
@@ -0,0 +1,125 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""A collection of Git repositories.
+
+See `IGitCollection` for more details.
+"""
+
+__metaclass__ = type
+__all__ = [
+    'IAllGitRepositories',
+    'IGitCollection',
+    'InvalidGitFilter',
+    ]
+
+from zope.interface import Interface
+
+
+class InvalidGitFilter(Exception):
+    """Raised when an `IGitCollection` cannot apply the given filter."""
+
+
+class IGitCollection(Interface):
+    """A collection of Git repositories.
+
+    An `IGitCollection` is an immutable collection of Git repositories. It
+    has two kinds of methods: filter methods and query methods.
+
+    Query methods get information about the contents of the collection. See
+    `IGitCollection.count` and `IGitCollection.getRepositories`.
+
+    Filter methods return new IGitCollection instances that have some sort
+    of restriction. Examples include `ownedBy`, `visibleByUser` and
+    `inProject`.
+
+    Implementations of this interface are not 'content classes'. That is, they
+    do not correspond to a particular row in the database.
+
+    This interface is intended for use within Launchpad, not to be exported as
+    a public API.
+    """
+
+    def count():
+        """The number of repositories in this collection."""
+
+    def is_empty():
+        """Is this collection empty?"""
+
+    def ownerCounts():
+        """Return the number of different repository owners.
+
+        :return: a tuple (individual_count, team_count) containing the
+            number of individuals and teams that own repositories in this
+            collection.
+        """
+
+    def getRepositories(eager_load=False):
+        """Return a result set of all repositories in this collection.
+
+        The returned result set will also join across the specified tables
+        as defined by the arguments to this function.  These extra tables
+        are joined specifically to allow the caller to sort on values not in
+        the GitRepository table itself.
+
+        :param eager_load: If True trigger eager loading of all the related
+            objects in the collection.
+        """
+
+    def getRepositoryIds():
+        """Return a result set of all repository ids in this collection."""
+
+    def getTeamsWithRepositories(person):
+        """Return the teams that person is a member of that have
+        repositories."""
+
+    def inProject(project):
+        """Restrict the collection to repositories in 'project'."""
+
+    def inProjectGroup(projectgroup):
+        """Restrict the collection to repositories in 'projectgroup'."""
+
+    def inDistribution(distribution):
+        """Restrict the collection to repositories in 'distribution'."""
+
+    def inDistributionSourcePackage(distro_source_package):
+        """Restrict to repositories in a package for a distribution."""
+
+    def isPersonal():
+        """Restrict the collection to personal repositories."""
+
+    def isPrivate():
+        """Restrict the collection to private repositories."""
+
+    def isExclusive():
+        """Restrict the collection to repositories owned by exclusive
+        people."""
+
+    def ownedBy(person):
+        """Restrict the collection to repositories owned by 'person'."""
+
+    def ownedByTeamMember(person):
+        """Restrict the collection to repositories owned by 'person' or a
+        team of which person is a member.
+        """
+
+    def registeredBy(person):
+        """Restrict the collection to repositories registered by 'person'."""
+
+    def search(term):
+        """Search the collection for repositories matching 'term'.
+
+        :param term: A string.
+        :return: A `ResultSet` of repositories that matched.
+        """
+
+    def visibleByUser(person):
+        """Restrict the collection to repositories that person is allowed to
+        see."""
+
+    def withIds(*repository_ids):
+        """Restrict the collection to repositories with the specified ids."""
+
+
+class IAllGitRepositories(IGitCollection):
+    """A `IGitCollection` representing all Git repositories in Launchpad."""

=== added file 'lib/lp/code/model/gitcollection.py'
--- lib/lp/code/model/gitcollection.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/model/gitcollection.py	2015-02-23 16:14:41 +0000
@@ -0,0 +1,327 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Implementations of `IGitCollection`."""
+
+__metaclass__ = type
+__all__ = [
+    'GenericGitCollection',
+    ]
+
+from lazr.uri import (
+    InvalidURIError,
+    URI,
+    )
+from storm.expr import (
+    Count,
+    In,
+    Join,
+    Select,
+    )
+from zope.component import getUtility
+from zope.interface import implements
+
+from lp.app.enums import PRIVATE_INFORMATION_TYPES
+from lp.code.interfaces.codehosting import LAUNCHPAD_SERVICES
+from lp.code.interfaces.gitcollection import (
+    IGitCollection,
+    InvalidGitFilter,
+    )
+from lp.code.interfaces.gitlookup import IGitLookup
+from lp.code.interfaces.gitrepository import (
+    user_has_special_git_repository_access,
+    )
+from lp.code.model.gitrepository import (
+    GitRepository,
+    get_git_repository_privacy_filter,
+    )
+from lp.registry.enums import EXCLUSIVE_TEAM_POLICY
+from lp.registry.model.person import Person
+from lp.registry.model.product import Product
+from lp.registry.model.teammembership import TeamParticipation
+from lp.services.database.bulk import load_related
+from lp.services.database.decoratedresultset import DecoratedResultSet
+from lp.services.database.interfaces import IStore
+from lp.services.propertycache import get_property_cache
+
+
+class GenericGitCollection:
+    """See `IGitCollection`."""
+
+    implements(IGitCollection)
+
+    def __init__(self, store=None, filter_expressions=None, tables=None):
+        """Construct a `GenericGitCollection`.
+
+        :param store: The store to look in for repositories. If not
+            specified, use the default store.
+        :param filter_expressions: A list of Storm expressions to
+            restrict the repositories in the collection. If unspecified,
+            then there will be no restrictions on the result set. That is,
+            all repositories in the store will be in the collection.
+        :param tables: A dict of Storm tables to the Join expression.  If an
+            expression in filter_expressions refers to a table, then that
+            table *must* be in this list.
+        """
+        self._store = store
+        if filter_expressions is None:
+            filter_expressions = []
+        self._filter_expressions = list(filter_expressions)
+        if tables is None:
+            tables = {}
+        self._tables = tables
+        self._user = None
+
+    def count(self):
+        """See `IGitCollection`."""
+        return self.getRepositories(eager_load=False).count()
+
+    def is_empty(self):
+        """See `IGitCollection`."""
+        return self.getRepositories(eager_load=False).is_empty()
+
+    def ownerCounts(self):
+        """See `IGitCollection`."""
+        is_team = Person.teamowner != None
+        owners = self._getRepositorySelect((GitRepository.owner_id,))
+        counts = dict(self.store.find(
+            (is_team, Count(Person.id)),
+            Person.id.is_in(owners)).group_by(is_team))
+        return (counts.get(False, 0), counts.get(True, 0))
+
+    @property
+    def store(self):
+        # Although you might think we could set the default value for store
+        # in the constructor, we can't.  The IStore utility is not available
+        # at the time that the ZCML is parsed, which means we get an error
+        # if this code is in the constructor.
+        # -- JonathanLange 2009-02-17.
+        if self._store is None:
+            return IStore(GitRepository)
+        else:
+            return self._store
+
+    def _filterBy(self, expressions, table=None, join=None):
+        """Return a subset of this collection, filtered by 'expressions'."""
+        # NOTE: JonathanLange 2009-02-17: We might be able to avoid the need
+        # for explicit 'tables' by harnessing Storm's table inference system.
+        # See http://paste.ubuntu.com/118711/ for one way to do that.
+        if table is not None and join is None:
+            raise InvalidGitFilter("Cannot specify a table without a join.")
+        if expressions is None:
+            expressions = []
+        tables = self._tables.copy()
+        if table is not None:
+            tables[table] = join
+        return self.__class__(
+            self.store, self._filter_expressions + expressions, tables)
+
+    def _getRepositorySelect(self, columns=(GitRepository.id,)):
+        """Return a Storm 'Select' for columns in this collection."""
+        repositories = self.getRepositories(
+            eager_load=False, find_expr=columns)
+        return repositories.get_plain_result_set()._get_select()
+
+    def _getRepositoryExpressions(self):
+        """Return the where expressions for this collection."""
+        return (self._filter_expressions +
+            self._getRepositoryVisibilityExpression())
+
+    def _getRepositoryVisibilityExpression(self):
+        """Return the where clauses for visibility."""
+        return []
+
+    def getRepositories(self, find_expr=GitRepository, eager_load=False):
+        """See `IGitCollection`."""
+        tables = [GitRepository] + list(set(self._tables.values()))
+        expressions = self._getRepositoryExpressions()
+        resultset = self.store.using(*tables).find(find_expr, *expressions)
+
+        def do_eager_load(rows):
+            repository_ids = set(repository.id for repository in rows)
+            if not repository_ids:
+                return
+            load_related(Product, rows, ['project_id'])
+            # So far have only needed the persons for their canonical_url - no
+            # need for validity etc in the API call.
+            load_related(Person, rows, ['owner_id', 'registrant_id'])
+
+        def cache_permission(repository):
+            if self._user:
+                get_property_cache(repository)._known_viewers = set(
+                    [self._user.id])
+            return repository
+
+        eager_load_hook = (
+            do_eager_load if eager_load and find_expr == GitRepository
+            else None)
+        return DecoratedResultSet(
+            resultset, pre_iter_hook=eager_load_hook,
+            result_decorator=cache_permission)
+
+    def getRepositoryIds(self):
+        """See `IGitCollection`."""
+        return self.getRepositories(
+            find_expr=GitRepository.id).get_plain_result_set()
+
+    def getTeamsWithRepositories(self, person):
+        """See `IGitCollection`."""
+        # This method doesn't entirely fit with the intent of the
+        # GitCollection conceptual model, but we're not quite sure how to
+        # fix it just yet.
+        repository_query = self._getRepositorySelect((GitRepository.owner_id,))
+        return self.store.find(
+            Person,
+            Person.id == TeamParticipation.teamID,
+            TeamParticipation.person == person,
+            TeamParticipation.team != person,
+            Person.id.is_in(repository_query))
+
+    def inProject(self, project):
+        """See `IGitCollection`."""
+        return self._filterBy([GitRepository.project == project])
+
+    def inProjectGroup(self, projectgroup):
+        """See `IGitCollection`."""
+        return self._filterBy(
+            [Product.projectgroup == projectgroup.id],
+            table=Product,
+            join=Join(Product, GitRepository.project == Product.id))
+
+    def inDistribution(self, distribution):
+        """See `IGitCollection`."""
+        return self._filterBy([GitRepository.distribution == distribution])
+
+    def inDistributionSourcePackage(self, distro_source_package):
+        """See `IGitCollection`."""
+        distribution = distro_source_package.distribution
+        sourcepackagename = distro_source_package.sourcepackagename
+        return self._filterBy(
+            [GitRepository.distribution == distribution,
+             GitRepository.sourcepackagename == sourcepackagename])
+
+    def isPersonal(self):
+        """See `IGitCollection`."""
+        return self._filterBy(
+            [GitRepository.project == None,
+             GitRepository.distribution == None])
+
+    def isPrivate(self):
+        """See `IGitCollection`."""
+        return self._filterBy(
+            [GitRepository.information_type.is_in(PRIVATE_INFORMATION_TYPES)])
+
+    def isExclusive(self):
+        """See `IGitCollection`."""
+        return self._filterBy(
+            [Person.membership_policy.is_in(EXCLUSIVE_TEAM_POLICY)],
+            table=Person,
+            join=Join(Person, GitRepository.owner_id == Person.id))
+
+    def ownedBy(self, person):
+        """See `IGitCollection`."""
+        return self._filterBy([GitRepository.owner == person])
+
+    def ownedByTeamMember(self, person):
+        """See `IGitCollection`."""
+        subquery = Select(
+            TeamParticipation.teamID,
+            where=TeamParticipation.personID == person.id)
+        return self._filterBy([In(GitRepository.owner_id, subquery)])
+
+    def registeredBy(self, person):
+        """See `IGitCollection`."""
+        return self._filterBy([GitRepository.registrant == person])
+
+    def _getExactMatch(self, term):
+        # Look up the repository by its URL, which handles both shortcuts
+        # and unique names.
+        repository = getUtility(IGitLookup).getByUrl(term)
+        if repository is not None:
+            return repository
+        # Fall back to searching by unique_name, stripping out the path if
+        # it's a URI.
+        try:
+            path = URI(term).path.strip("/")
+        except InvalidURIError:
+            path = term
+        return getUtility(IGitLookup).getByUniqueName(path)
+
+    def search(self, term):
+        """See `IGitCollection`."""
+        repository = self._getExactMatch(term)
+        if repository:
+            collection = self._filterBy([GitRepository.id == repository.id])
+        else:
+            term = unicode(term)
+            # Filter by name.
+            field = GitRepository.name
+            # Except if the term contains /, when we use unique_name.
+            # XXX cjwatson 2015-02-06: Disabled until the URL format settles
+            # down, at which point we can make GitRepository.unique_name a
+            # trigger-maintained column rather than a property.
+            #if '/' in term:
+            #    field = GitRepository.unique_name
+            collection = self._filterBy(
+                [field.lower().contains_string(term.lower())])
+        return collection.getRepositories(eager_load=False).order_by(
+            GitRepository.name, GitRepository.id)
+
+    def visibleByUser(self, person):
+        """See `IGitCollection`."""
+        if (person == LAUNCHPAD_SERVICES or
+            user_has_special_git_repository_access(person)):
+            return self
+        if person is None:
+            return AnonymousGitCollection(
+                self._store, self._filter_expressions, self._tables)
+        return VisibleGitCollection(
+            person, self._store, self._filter_expressions, self._tables)
+
+    def withIds(self, *repository_ids):
+        """See `IGitCollection`."""
+        return self._filterBy([GitRepository.id.is_in(repository_ids)])
+
+
+class AnonymousGitCollection(GenericGitCollection):
+    """Repository collection that only shows public repositories."""
+
+    def _getRepositoryVisibilityExpression(self):
+        """Return the where clauses for visibility."""
+        return get_git_repository_privacy_filter(None)
+
+
+class VisibleGitCollection(GenericGitCollection):
+    """A repository collection that has special logic for visibility."""
+
+    def __init__(self, user, store=None, filter_expressions=None, tables=None):
+        super(VisibleGitCollection, self).__init__(
+            store=store, filter_expressions=filter_expressions, tables=tables)
+        self._user = user
+
+    def _filterBy(self, expressions, table=None, join=None):
+        """Return a subset of this collection, filtered by 'expressions'."""
+        # NOTE: JonathanLange 2009-02-17: We might be able to avoid the need
+        # for explicit 'tables' by harnessing Storm's table inference system.
+        # See http://paste.ubuntu.com/118711/ for one way to do that.
+        if table is not None and join is None:
+            raise InvalidGitFilter("Cannot specify a table without a join.")
+        if expressions is None:
+            expressions = []
+        tables = self._tables.copy()
+        if table is not None:
+            tables[table] = join
+        return self.__class__(
+            self._user, self.store, self._filter_expressions + expressions)
+
+    def _getRepositoryVisibilityExpression(self):
+        """Return the where clauses for visibility."""
+        return get_git_repository_privacy_filter(self._user)
+
+    def visibleByUser(self, person):
+        """See `IGitCollection`."""
+        if person == self._user:
+            return self
+        raise InvalidGitFilter(
+            "Cannot filter for Git repositories visible by user %r, already "
+            "filtering for %r" % (person, self._user))

=== added file 'lib/lp/code/model/tests/test_gitcollection.py'
--- lib/lp/code/model/tests/test_gitcollection.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/model/tests/test_gitcollection.py	2015-02-23 16:14:41 +0000
@@ -0,0 +1,715 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for Git repository collections."""
+
+__metaclass__ = type
+
+from testtools.matchers import Equals
+from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
+
+from lp.app.enums import InformationType
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.app.interfaces.services import IService
+from lp.code.interfaces.codehosting import LAUNCHPAD_SERVICES
+from lp.code.interfaces.gitcollection import (
+    IAllGitRepositories,
+    IGitCollection,
+    )
+from lp.code.model.gitcollection import GenericGitCollection
+from lp.code.model.gitrepository import GitRepository
+from lp.registry.enums import PersonVisibility
+from lp.registry.interfaces.person import TeamMembershipPolicy
+from lp.registry.model.persondistributionsourcepackage import (
+    PersonDistributionSourcePackage,
+    )
+from lp.registry.model.personproduct import PersonProduct
+from lp.services.database.interfaces import IStore
+from lp.services.webapp.publisher import canonical_url
+from lp.testing import (
+    person_logged_in,
+    StormStatementRecorder,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.matchers import HasQueryCount
+
+
+class TestGitCollectionAdaptation(TestCaseWithFactory):
+    """Check that certain objects can be adapted to a Git repository
+    collection."""
+
+    layer = DatabaseFunctionalLayer
+
+    def assertCollection(self, target):
+        self.assertIsNotNone(IGitCollection(target, None))
+
+    def test_project(self):
+        # A project can be adapted to a Git repository collection.
+        self.assertCollection(self.factory.makeProduct())
+
+    def test_project_group(self):
+        # A project group can be adapted to a Git repository collection.
+        self.assertCollection(self.factory.makeProject())
+
+    def test_distribution(self):
+        # A distribution can be adapted to a Git repository collection.
+        self.assertCollection(self.factory.makeDistribution())
+
+    def test_distribution_source_package(self):
+        # A distribution source package can be adapted to a Git repository
+        # collection.
+        self.assertCollection(self.factory.makeDistributionSourcePackage())
+
+    def test_person(self):
+        # A person can be adapted to a Git repository collection.
+        self.assertCollection(self.factory.makePerson())
+
+    def test_person_product(self):
+        # A PersonProduct can be adapted to a Git repository collection.
+        project = self.factory.makeProduct()
+        self.assertCollection(PersonProduct(project.owner, project))
+
+    def test_person_distribution_source_package(self):
+        # A PersonDistributionSourcePackage can be adapted to a Git
+        # repository collection.
+        dsp = self.factory.makeDistributionSourcePackage()
+        self.assertCollection(
+            PersonDistributionSourcePackage(dsp.distribution.owner, dsp))
+
+
+class TestGenericGitCollection(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestGenericGitCollection, self).setUp()
+        self.store = IStore(GitRepository)
+
+    def test_provides_gitcollection(self):
+        # `GenericGitCollection` provides the `IGitCollection`
+        # interface.
+        self.assertProvides(GenericGitCollection(self.store), IGitCollection)
+
+    def test_getRepositories_no_filter_no_repositories(self):
+        # If no filter is specified, then the collection is of all
+        # repositories in Launchpad.  By default, there are no repositories.
+        collection = GenericGitCollection(self.store)
+        self.assertEqual([], list(collection.getRepositories()))
+
+    def test_getRepositories_no_filter(self):
+        # If no filter is specified, then the collection is of all
+        # repositories in Launchpad.
+        collection = GenericGitCollection(self.store)
+        repository = self.factory.makeGitRepository()
+        self.assertEqual([repository], list(collection.getRepositories()))
+
+    def test_getRepositories_project_filter(self):
+        # If the specified filter is for the repositories of a particular
+        # project, then the collection contains only repositories of that
+        # project.
+        repository = self.factory.makeGitRepository()
+        self.factory.makeGitRepository()
+        collection = GenericGitCollection(
+            self.store, [GitRepository.project == repository.target])
+        self.assertEqual([repository], list(collection.getRepositories()))
+
+    def test_getRepositories_caches_viewers(self):
+        # getRepositories() caches the user as a known viewer so that
+        # repository.visibleByUser() does not have to hit the database.
+        collection = GenericGitCollection(self.store)
+        owner = self.factory.makePerson()
+        project = self.factory.makeProduct()
+        repository = self.factory.makeGitRepository(
+            owner=owner, target=project,
+            information_type=InformationType.USERDATA)
+        someone = self.factory.makePerson()
+        with person_logged_in(owner):
+            getUtility(IService, 'sharing').ensureAccessGrants(
+                [someone], owner, gitrepositories=[repository],
+                ignore_permissions=True)
+        [repository] = list(collection.visibleByUser(
+            someone).getRepositories())
+        with StormStatementRecorder() as recorder:
+            self.assertTrue(repository.visibleByUser(someone))
+            self.assertThat(recorder, HasQueryCount(Equals(0)))
+
+    def test_getRepositoryIds(self):
+        repository = self.factory.makeGitRepository()
+        self.factory.makeGitRepository()
+        collection = GenericGitCollection(
+            self.store, [GitRepository.project == repository.target])
+        self.assertEqual([repository.id], list(collection.getRepositoryIds()))
+
+    def test_count(self):
+        # The 'count' property of a collection is the number of elements in
+        # the collection.
+        collection = GenericGitCollection(self.store)
+        self.assertEqual(0, collection.count())
+        for i in range(3):
+            self.factory.makeGitRepository()
+        self.assertEqual(3, collection.count())
+
+    def test_count_respects_filter(self):
+        # If a collection is a subset of all possible repositories, then the
+        # count will be the size of that subset.  That is, 'count' respects
+        # any filters that are applied.
+        repository = self.factory.makeGitRepository()
+        self.factory.makeGitRepository()
+        collection = GenericGitCollection(
+            self.store, [GitRepository.project == repository.target])
+        self.assertEqual(1, collection.count())
+
+
+class TestGitCollectionFilters(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        TestCaseWithFactory.setUp(self)
+        self.all_repositories = getUtility(IAllGitRepositories)
+
+    def test_order_by_repository_name(self):
+        # The result of getRepositories() can be ordered by
+        # `GitRepository.name`, no matter what filters are applied.
+        aardvark = self.factory.makeProduct(name='aardvark')
+        badger = self.factory.makeProduct(name='badger')
+        repository_a = self.factory.makeGitRepository(target=aardvark)
+        repository_b = self.factory.makeGitRepository(target=badger)
+        person = self.factory.makePerson()
+        repository_c = self.factory.makeGitRepository(
+            owner=person, target=person)
+        self.assertEqual(
+            sorted([repository_a, repository_b, repository_c]),
+            sorted(self.all_repositories.getRepositories()
+                 .order_by(GitRepository.name)))
+
+    def test_count_respects_visibleByUser_filter(self):
+        # IGitCollection.count() returns the number of repositories that
+        # getRepositories() yields, even when the visibleByUser filter is
+        # applied.
+        repository = self.factory.makeGitRepository()
+        self.factory.makeGitRepository(
+            information_type=InformationType.USERDATA)
+        collection = self.all_repositories.visibleByUser(repository.owner)
+        self.assertEqual(1, collection.getRepositories().count())
+        self.assertEqual(1, len(list(collection.getRepositories())))
+        self.assertEqual(1, collection.count())
+
+    def test_ownedBy(self):
+        # 'ownedBy' returns a new collection restricted to repositories
+        # owned by the given person.
+        repository = self.factory.makeGitRepository()
+        self.factory.makeGitRepository()
+        collection = self.all_repositories.ownedBy(repository.owner)
+        self.assertEqual([repository], list(collection.getRepositories()))
+
+    def test_ownedByTeamMember(self):
+        # 'ownedBy' returns a new collection restricted to repositories
+        # owned by any team of which the given person is a member.
+        person = self.factory.makePerson()
+        team = self.factory.makeTeam(members=[person])
+        repository = self.factory.makeGitRepository(owner=team)
+        self.factory.makeGitRepository()
+        collection = self.all_repositories.ownedByTeamMember(person)
+        self.assertEqual([repository], list(collection.getRepositories()))
+
+    def test_in_project(self):
+        # 'inProject' returns a new collection restricted to repositories in
+        # the given project.
+        #
+        # NOTE: JonathanLange 2009-02-11: Maybe this should be a more
+        # generic method called 'onTarget' that takes a person, package or
+        # project.
+        repository = self.factory.makeGitRepository()
+        self.factory.makeGitRepository()
+        collection = self.all_repositories.inProject(repository.target)
+        self.assertEqual([repository], list(collection.getRepositories()))
+
+    def test_inProjectGroup(self):
+        # 'inProjectGroup' returns a new collection restricted to
+        # repositories in the given project group.
+        repository = self.factory.makeGitRepository()
+        self.factory.makeGitRepository()
+        projectgroup = self.factory.makeProject()
+        removeSecurityProxy(repository.target).projectgroup = projectgroup
+        collection = self.all_repositories.inProjectGroup(projectgroup)
+        self.assertEqual([repository], list(collection.getRepositories()))
+
+    def test_isExclusive(self):
+        # 'isExclusive' is restricted to repositories owned by exclusive
+        # teams and users.
+        user = self.factory.makePerson()
+        team = self.factory.makeTeam(
+            membership_policy=TeamMembershipPolicy.RESTRICTED)
+        other_team = self.factory.makeTeam(
+            membership_policy=TeamMembershipPolicy.OPEN)
+        team_repository = self.factory.makeGitRepository(owner=team)
+        user_repository = self.factory.makeGitRepository(owner=user)
+        self.factory.makeGitRepository(owner=other_team)
+        collection = self.all_repositories.isExclusive()
+        self.assertContentEqual(
+            [team_repository, user_repository],
+            list(collection.getRepositories()))
+
+    def test_inProject_and_isExclusive(self):
+        # 'inProject' and 'isExclusive' can combine to form a collection
+        # that is restricted to repositories of a particular project owned
+        # by exclusive teams and users.
+        team = self.factory.makeTeam(
+            membership_policy=TeamMembershipPolicy.RESTRICTED)
+        other_team = self.factory.makeTeam(
+            membership_policy=TeamMembershipPolicy.OPEN)
+        project = self.factory.makeProduct()
+        repository = self.factory.makeGitRepository(target=project, owner=team)
+        self.factory.makeGitRepository(owner=team)
+        self.factory.makeGitRepository(target=project, owner=other_team)
+        collection = self.all_repositories.inProject(project).isExclusive()
+        self.assertEqual([repository], list(collection.getRepositories()))
+        collection = self.all_repositories.isExclusive().inProject(project)
+        self.assertEqual([repository], list(collection.getRepositories()))
+
+    def test_ownedBy_and_inProject(self):
+        # 'ownedBy' and 'inProject' can combine to form a collection that is
+        # restricted to repositories of a particular project owned by a
+        # particular person.
+        person = self.factory.makePerson()
+        project = self.factory.makeProduct()
+        repository = self.factory.makeGitRepository(
+            target=project, owner=person)
+        self.factory.makeGitRepository(owner=person)
+        self.factory.makeGitRepository(target=project)
+        collection = self.all_repositories.inProject(project).ownedBy(person)
+        self.assertEqual([repository], list(collection.getRepositories()))
+        collection = self.all_repositories.ownedBy(person).inProject(project)
+        self.assertEqual([repository], list(collection.getRepositories()))
+
+    def test_ownedBy_and_isPrivate(self):
+        # 'ownedBy' and 'isPrivate' can combine to form a collection that is
+        # restricted to private repositories owned by a particular person.
+        person = self.factory.makePerson()
+        project = self.factory.makeProduct()
+        repository = self.factory.makeGitRepository(
+            target=project, owner=person,
+            information_type=InformationType.USERDATA)
+        self.factory.makeGitRepository(owner=person)
+        self.factory.makeGitRepository(target=project)
+        collection = self.all_repositories.isPrivate().ownedBy(person)
+        self.assertEqual([repository], list(collection.getRepositories()))
+        collection = self.all_repositories.ownedBy(person).isPrivate()
+        self.assertEqual([repository], list(collection.getRepositories()))
+
+    def test_ownedByTeamMember_and_inProject(self):
+        # 'ownedBy' and 'inProject' can combine to form a collection that is
+        # restricted to repositories of a particular project owned by a
+        # particular person or team of which the person is a member.
+        person = self.factory.makePerson()
+        team = self.factory.makeTeam(members=[person])
+        project = self.factory.makeProduct()
+        repository = self.factory.makeGitRepository(
+            target=project, owner=person)
+        repository2 = self.factory.makeGitRepository(
+            target=project, owner=team)
+        self.factory.makeGitRepository(owner=person)
+        self.factory.makeGitRepository(target=project)
+        project_repositories = self.all_repositories.inProject(project)
+        collection = project_repositories.ownedByTeamMember(person)
+        self.assertContentEqual(
+            [repository, repository2], collection.getRepositories())
+        person_repositories = self.all_repositories.ownedByTeamMember(person)
+        collection = person_repositories.inProject(project)
+        self.assertContentEqual(
+            [repository, repository2], collection.getRepositories())
+
+    def test_in_distribution(self):
+        # 'inDistribution' returns a new collection that only has
+        # repositories that are package repositories associated with the
+        # distribution specified.
+        distro = self.factory.makeDistribution()
+        # Make two repositories in the same distribution, but different
+        # source packages.
+        dsp = self.factory.makeDistributionSourcePackage(distribution=distro)
+        repository = self.factory.makeGitRepository(target=dsp)
+        dsp2 = self.factory.makeDistributionSourcePackage(distribution=distro)
+        repository2 = self.factory.makeGitRepository(target=dsp2)
+        # Another repository in a different distribution.
+        self.factory.makeGitRepository(
+            target=self.factory.makeDistributionSourcePackage())
+        # And a project repository.
+        self.factory.makeGitRepository()
+        collection = self.all_repositories.inDistribution(distro)
+        self.assertEqual(
+            sorted([repository, repository2]),
+            sorted(collection.getRepositories()))
+
+    def test_in_distribution_source_package(self):
+        # 'inDistributionSourcePackage' returns a new collection that only
+        # has repositories for the source package in the distribution.
+        distro = self.factory.makeDistribution()
+        dsp = self.factory.makeDistributionSourcePackage(distribution=distro)
+        dsp_other_distro = self.factory.makeDistributionSourcePackage()
+        repository = self.factory.makeGitRepository(target=dsp)
+        repository2 = self.factory.makeGitRepository(target=dsp)
+        self.factory.makeGitRepository(target=dsp_other_distro)
+        self.factory.makeGitRepository()
+        collection = self.all_repositories.inDistributionSourcePackage(dsp)
+        self.assertEqual(
+            sorted([repository, repository2]),
+            sorted(collection.getRepositories()))
+
+    def test_withIds(self):
+        # 'withIds' returns a new collection that only has repositories with
+        # the given ids.
+        repository1 = self.factory.makeGitRepository()
+        repository2 = self.factory.makeGitRepository()
+        self.factory.makeGitRepository()
+        ids = [repository1.id, repository2.id]
+        collection = self.all_repositories.withIds(*ids)
+        self.assertEqual(
+            sorted([repository1, repository2]),
+            sorted(collection.getRepositories()))
+
+    def test_registeredBy(self):
+        # 'registeredBy' returns a new collection that only has repositories
+        # that were registered by the given user.
+        registrant = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(
+            owner=registrant, registrant=registrant)
+        removeSecurityProxy(repository).owner = self.factory.makePerson()
+        self.factory.makeGitRepository()
+        collection = self.all_repositories.registeredBy(registrant)
+        self.assertEqual([repository], list(collection.getRepositories()))
+
+
+class TestGenericGitCollectionVisibleFilter(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        TestCaseWithFactory.setUp(self)
+        self.public_repository = self.factory.makeGitRepository(name=u'public')
+        self.private_repository = self.factory.makeGitRepository(
+            name=u'private', information_type=InformationType.USERDATA)
+        self.all_repositories = getUtility(IAllGitRepositories)
+
+    def test_all_repositories(self):
+        # Without the visibleByUser filter, all repositories are in the
+        # collection.
+        self.assertEqual(
+            sorted([self.public_repository, self.private_repository]),
+            sorted(self.all_repositories.getRepositories()))
+
+    def test_anonymous_sees_only_public(self):
+        # Anonymous users can see only public repositories.
+        repositories = self.all_repositories.visibleByUser(None)
+        self.assertEqual(
+            [self.public_repository], list(repositories.getRepositories()))
+
+    def test_visibility_then_project(self):
+        # We can apply other filters after applying the visibleByUser filter.
+        # Create another public repository.
+        self.factory.makeGitRepository()
+        repositories = self.all_repositories.visibleByUser(None).inProject(
+            self.public_repository.target).getRepositories()
+        self.assertEqual([self.public_repository], list(repositories))
+
+    def test_random_person_sees_only_public(self):
+        # Logged in users with no special permissions can see only public
+        # repositories.
+        person = self.factory.makePerson()
+        repositories = self.all_repositories.visibleByUser(person)
+        self.assertEqual(
+            [self.public_repository], list(repositories.getRepositories()))
+
+    def test_owner_sees_own_repositories(self):
+        # Users can always see the repositories that they own, as well as public
+        # repositories.
+        owner = removeSecurityProxy(self.private_repository).owner
+        repositories = self.all_repositories.visibleByUser(owner)
+        self.assertEqual(
+            sorted([self.public_repository, self.private_repository]),
+            sorted(repositories.getRepositories()))
+
+    def test_launchpad_services_sees_all(self):
+        # The LAUNCHPAD_SERVICES special user sees *everything*.
+        repositories = self.all_repositories.visibleByUser(LAUNCHPAD_SERVICES)
+        self.assertEqual(
+            sorted(self.all_repositories.getRepositories()),
+            sorted(repositories.getRepositories()))
+
+    def test_admins_see_all(self):
+        # Launchpad administrators see *everything*.
+        admin = self.factory.makePerson()
+        admin_team = removeSecurityProxy(
+            getUtility(ILaunchpadCelebrities).admin)
+        admin_team.addMember(admin, admin_team.teamowner)
+        repositories = self.all_repositories.visibleByUser(admin)
+        self.assertEqual(
+            sorted(self.all_repositories.getRepositories()),
+            sorted(repositories.getRepositories()))
+
+    def test_private_teams_see_own_private_personal_repositories(self):
+        # Private teams are given an access grant to see their private
+        # personal repositories.
+        team_owner = self.factory.makePerson()
+        team = self.factory.makeTeam(
+            visibility=PersonVisibility.PRIVATE,
+            membership_policy=TeamMembershipPolicy.MODERATED,
+            owner=team_owner)
+        with person_logged_in(team_owner):
+            personal_repository = self.factory.makeGitRepository(
+                owner=team, target=team,
+                information_type=InformationType.USERDATA)
+            # The team is automatically subscribed to the repository since
+            # they are the owner.  We want to unsubscribe them so that they
+            # lose access conferred via subscription and rely instead on the
+            # APG.
+            # XXX cjwatson 2015-02-05: Uncomment this once
+            # GitRepositorySubscriptions exist.
+            #personal_repository.unsubscribe(team, team_owner, True)
+            # Make another personal repository the team can't see.
+            other_person = self.factory.makePerson()
+            self.factory.makeGitRepository(
+                owner=other_person, target=other_person,
+                information_type=InformationType.USERDATA)
+            repositories = self.all_repositories.visibleByUser(team)
+        self.assertEqual(
+            sorted([self.public_repository, personal_repository]),
+            sorted(repositories.getRepositories()))
+
+
+class TestSearch(TestCaseWithFactory):
+    """Tests for IGitCollection.search()."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        TestCaseWithFactory.setUp(self)
+        self.collection = getUtility(IAllGitRepositories)
+
+    def test_exact_match_unique_name(self):
+        # If you search for a unique name of a repository that exists,
+        # you'll get a single result with a repository with that repository
+        # name.
+        repository = self.factory.makeGitRepository()
+        self.factory.makeGitRepository()
+        search_results = self.collection.search(repository.unique_name)
+        self.assertEqual([repository], list(search_results))
+
+    def test_unique_name_match_not_in_collection(self):
+        # If you search for a unique name of a repository that does not
+        # exist, you'll get an empty result set.
+        repository = self.factory.makeGitRepository()
+        collection = self.collection.inProject(self.factory.makeProduct())
+        search_results = collection.search(repository.unique_name)
+        self.assertEqual([], list(search_results))
+
+    def test_exact_match_launchpad_url(self):
+        # If you search for the Launchpad URL of a repository, and there is
+        # a repository with that URL, then you get a single result with that
+        # repository.
+        repository = self.factory.makeGitRepository()
+        self.factory.makeGitRepository()
+        search_results = self.collection.search(repository.getCodebrowseUrl())
+        self.assertEqual([repository], list(search_results))
+
+    def test_exact_match_with_lp_colon_url(self):
+        repository = self.factory.makeGitRepository()
+        lp_name = 'lp:' + repository.unique_name
+        search_results = self.collection.search(lp_name)
+        self.assertEqual([repository], list(search_results))
+
+    def test_exact_match_bad_url(self):
+        search_results = self.collection.search('http:hahafail')
+        self.assertEqual([], list(search_results))
+
+    def test_exact_match_git_identity(self):
+        # If you search for the Git identity of a repository, then you get a
+        # single result with that repository.
+        repository = self.factory.makeGitRepository()
+        self.factory.makeGitRepository()
+        search_results = self.collection.search(repository.git_identity)
+        self.assertEqual([repository], list(search_results))
+
+    def test_exact_match_git_identity_development_focus(self):
+        # If you search for the development focus and it is set, you get a
+        # single result with the development focus repository.
+        fooix = self.factory.makeProduct(name='fooix')
+        repository = self.factory.makeGitRepository(
+            owner=fooix.owner, target=fooix)
+        with person_logged_in(fooix.owner):
+            fooix.setDefaultGitRepository(repository)
+        self.factory.makeGitRepository()
+        search_results = self.collection.search('lp:fooix')
+        self.assertEqual([repository], list(search_results))
+
+    def test_bad_match_git_identity_development_focus(self):
+        # If you search for the development focus for a project where one
+        # isn't set, you get an empty search result.
+        fooix = self.factory.makeProduct(name='fooix')
+        self.factory.makeGitRepository(target=fooix)
+        self.factory.makeGitRepository()
+        search_results = self.collection.search('lp:fooix')
+        self.assertEqual([], list(search_results))
+
+    def test_bad_match_git_identity_no_project(self):
+        # If you search for the development focus for a project where one
+        # isn't set, you get an empty search result.
+        self.factory.makeGitRepository()
+        search_results = self.collection.search('lp:fooix')
+        self.assertEqual([], list(search_results))
+
+    def test_exact_match_url_trailing_slash(self):
+        # Sometimes, users are inconsiderately unaware of our arbitrary
+        # database restrictions and will put trailing slashes on their
+        # search queries.  Rather bravely, we refuse to explode in this
+        # case.
+        repository = self.factory.makeGitRepository()
+        self.factory.makeGitRepository()
+        search_results = self.collection.search(
+            repository.getCodebrowseUrl() + '/')
+        self.assertEqual([repository], list(search_results))
+
+    def test_match_exact_repository_name(self):
+        # search returns all repositories with the same name as the search
+        # term.
+        repository1 = self.factory.makeGitRepository(name=u'foo')
+        repository2 = self.factory.makeGitRepository(name=u'foo')
+        self.factory.makeGitRepository()
+        search_results = self.collection.search('foo')
+        self.assertEqual(
+            sorted([repository1, repository2]), sorted(search_results))
+
+    def disabled_test_match_against_unique_name(self):
+        # XXX cjwatson 2015-02-06: Disabled until the URL format settles
+        # down.
+        repository = self.factory.makeGitRepository(name=u'fooa')
+        search_term = repository.target.name + '/foo'
+        search_results = self.collection.search(search_term)
+        self.assertEqual([repository], list(search_results))
+
+    def test_match_sub_repository_name(self):
+        # search returns all repositories which have a name of which the
+        # search term is a substring.
+        repository1 = self.factory.makeGitRepository(name=u'afoo')
+        repository2 = self.factory.makeGitRepository(name=u'foob')
+        self.factory.makeGitRepository()
+        search_results = self.collection.search('foo')
+        self.assertEqual(
+            sorted([repository1, repository2]), sorted(search_results))
+
+    def test_match_ignores_case(self):
+        repository = self.factory.makeGitRepository(name=u'foobar')
+        search_results = self.collection.search('FOOBAR')
+        self.assertEqual([repository], list(search_results))
+
+    def test_dont_match_project_if_in_project(self):
+        # If the container is restricted to the project, then we don't match
+        # the project name.
+        project = self.factory.makeProduct('foo')
+        repository1 = self.factory.makeGitRepository(
+            target=project, name=u'foo')
+        self.factory.makeGitRepository(target=project, name=u'bar')
+        search_results = self.collection.inProject(project).search('foo')
+        self.assertEqual([repository1], list(search_results))
+
+
+class TestGetTeamsWithRepositories(TestCaseWithFactory):
+    """Test the IGitCollection.getTeamsWithRepositories method."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        TestCaseWithFactory.setUp(self)
+        self.all_repositories = getUtility(IAllGitRepositories)
+
+    def test_no_teams(self):
+        # If the user is not a member of any teams, there are no results,
+        # even if the person owns a repository themselves.
+        person = self.factory.makePerson()
+        self.factory.makeGitRepository(owner=person)
+        teams = list(self.all_repositories.getTeamsWithRepositories(person))
+        self.assertEqual([], teams)
+
+    def test_team_repositories(self):
+        # Return the teams that the user is in and that have repositories.
+        person = self.factory.makePerson()
+        team = self.factory.makeTeam(owner=person)
+        self.factory.makeGitRepository(owner=team)
+        # Make another team that person is in that has no repositories.
+        self.factory.makeTeam(owner=person)
+        teams = list(self.all_repositories.getTeamsWithRepositories(person))
+        self.assertEqual([team], teams)
+
+    def test_respects_restrictions(self):
+        # Create a team with repositories on a project, and another
+        # repository in a different namespace owned by a different team that
+        # the person is a member of.  Restricting the collection will return
+        # just the teams that have repositories in that restricted
+        # collection.
+        person = self.factory.makePerson()
+        team1 = self.factory.makeTeam(owner=person)
+        repository = self.factory.makeGitRepository(owner=team1)
+        # Make another team that person is in that owns a repository in a
+        # different namespace to the namespace of the repository owned by team1.
+        team2 = self.factory.makeTeam(owner=person)
+        self.factory.makeGitRepository(owner=team2)
+        collection = self.all_repositories.inProject(repository.target)
+        teams = list(collection.getTeamsWithRepositories(person))
+        self.assertEqual([team1], teams)
+
+
+class TestGitCollectionOwnerCounts(TestCaseWithFactory):
+    """Test IGitCollection.ownerCounts."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        TestCaseWithFactory.setUp(self)
+        self.all_repositories = getUtility(IAllGitRepositories)
+
+    def test_no_repositories(self):
+        # If there are no repositories, we should get zero counts for both.
+        person_count, team_count = self.all_repositories.ownerCounts()
+        self.assertEqual(0, person_count)
+        self.assertEqual(0, team_count)
+
+    def test_individual_repository_owners(self):
+        # Repositories owned by an individual are returned as the first part
+        # of the tuple.
+        self.factory.makeGitRepository()
+        self.factory.makeGitRepository()
+        person_count, team_count = self.all_repositories.ownerCounts()
+        self.assertEqual(2, person_count)
+        self.assertEqual(0, team_count)
+
+    def test_team_repository_owners(self):
+        # Repositories owned by teams are returned as the second part of the
+        # tuple.
+        self.factory.makeGitRepository(owner=self.factory.makeTeam())
+        self.factory.makeGitRepository(owner=self.factory.makeTeam())
+        person_count, team_count = self.all_repositories.ownerCounts()
+        self.assertEqual(0, person_count)
+        self.assertEqual(2, team_count)
+
+    def test_multiple_repositories_owned_counted_once(self):
+        # Confirming that a person that owns multiple repositories only gets
+        # counted once.
+        individual = self.factory.makePerson()
+        team = self.factory.makeTeam()
+        for owner in [individual, individual, team, team]:
+            self.factory.makeGitRepository(owner=owner)
+        person_count, team_count = self.all_repositories.ownerCounts()
+        self.assertEqual(1, person_count)
+        self.assertEqual(1, team_count)
+
+    def test_counts_limited_by_collection(self):
+        # For collections that are constrained in some way, we only get
+        # counts for the constrained collection.
+        r1 = self.factory.makeGitRepository()
+        project = r1.target
+        self.factory.makeGitRepository()
+        collection = self.all_repositories.inProject(project)
+        person_count, team_count = collection.ownerCounts()
+        self.assertEqual(1, person_count)


Follow ups