← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Colin Watson has proposed merging ~cjwatson/launchpad:oci-git-lookup into launchpad:master with ~cjwatson/launchpad:oci-project-basic-views as a prerequisite.

Commit message:
Add git lookup mechanisms for OCI projects

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1847444 in Launchpad itself: "Support OCI image building"
  https://bugs.launchpad.net/launchpad/+bug/1847444

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

Based on work by Tom, with some tidying-up and more tests by me.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:oci-git-lookup into launchpad:master.
diff --git a/lib/lp/code/configure.zcml b/lib/lp/code/configure.zcml
index ef98d7f..8f50dce 100644
--- a/lib/lp/code/configure.zcml
+++ b/lib/lp/code/configure.zcml
@@ -894,7 +894,7 @@
     <allow interface="lp.code.interfaces.gitnamespace.IGitNamespace" />
     <allow interface="lp.code.interfaces.gitnamespace.IGitNamespacePolicy" />
   </class>
-  <class class="lp.code.model.gitnamespace.DistributionOCIGitNamespace">
+  <class class="lp.code.model.gitnamespace.OCIProjectGitNamespace">
     <allow interface="lp.code.interfaces.gitnamespace.IGitNamespace" />
     <allow interface="lp.code.interfaces.gitnamespace.IGitNamespacePolicy" />
   </class>
@@ -1049,6 +1049,7 @@
 
   <adapter factory="lp.code.model.defaultgit.ProjectDefaultGitRepository" />
   <adapter factory="lp.code.model.defaultgit.PackageDefaultGitRepository" />
+  <adapter factory="lp.code.model.defaultgit.OCIProjectDefaultGitRepository" />
   <adapter factory="lp.code.model.defaultgit.OwnerProjectDefaultGitRepository" />
   <adapter factory="lp.code.model.defaultgit.OwnerPackageDefaultGitRepository" />
 
@@ -1069,6 +1070,7 @@
   <adapter factory="lp.code.model.gitlookup.ProjectGitTraversable" />
   <adapter factory="lp.code.model.gitlookup.DistributionGitTraversable" />
   <adapter factory="lp.code.model.gitlookup.DistributionSourcePackageGitTraversable" />
+  <adapter factory="lp.code.model.gitlookup.DistributionOCIProjectGitTraversable" />
 
   <!-- Git hosting -->
   <securedutility
diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py
index 25be4a5..7a6d9d9 100644
--- a/lib/lp/code/interfaces/gitrepository.py
+++ b/lib/lp/code/interfaces/gitrepository.py
@@ -1071,7 +1071,7 @@ class IGitRepositorySet(Interface):
             title=_("Git repository"), required=False, schema=IGitRepository))
     @export_write_operation()
     @operation_for_version("devel")
-    def setDefaultRepository(target, repository):
+    def setDefaultRepository(target, repository, force_oci=False):
         """Set the default repository for a target.
 
         :param target: An `IHasGitRepositories`.
diff --git a/lib/lp/code/model/defaultgit.py b/lib/lp/code/model/defaultgit.py
index bb55394..0a87759 100644
--- a/lib/lp/code/model/defaultgit.py
+++ b/lib/lp/code/model/defaultgit.py
@@ -15,6 +15,7 @@ from lp.code.interfaces.defaultgit import ICanHasDefaultGitRepository
 from lp.registry.interfaces.distributionsourcepackage import (
     IDistributionSourcePackage,
     )
+from lp.registry.interfaces.ociproject import IOCIProject
 from lp.registry.interfaces.persondistributionsourcepackage import (
     IPersonDistributionSourcePackage,
     )
@@ -73,6 +74,22 @@ class PackageDefaultGitRepository(BaseDefaultGitRepository):
             self.context.sourcepackagename.name)
 
 
+@adapter(IOCIProject)
+@implementer(ICanHasDefaultGitRepository)
+class OCIProjectDefaultGitRepository(BaseDefaultGitRepository):
+    """Implement a default Git repository for an OCI project."""
+
+    sort_order = 0
+
+    def __init__(self, oci_project):
+        self.context = oci_project
+
+    @property
+    def path(self):
+        """See `ICanHasDefaultGitRepository`."""
+        return "%s/+oci/%s" % (self.context.pillar.name, self.context.name)
+
+
 @adapter(IPersonProduct)
 @implementer(ICanHasDefaultGitRepository)
 class OwnerProjectDefaultGitRepository(BaseDefaultGitRepository):
diff --git a/lib/lp/code/model/gitlookup.py b/lib/lp/code/model/gitlookup.py
index 7767c63..9c01af4 100644
--- a/lib/lp/code/model/gitlookup.py
+++ b/lib/lp/code/model/gitlookup.py
@@ -35,11 +35,15 @@ from lp.code.interfaces.gitnamespace import IGitNamespaceSet
 from lp.code.interfaces.gitrepository import IGitRepositorySet
 from lp.code.interfaces.hasgitrepositories import IHasGitRepositories
 from lp.code.model.gitrepository import GitRepository
-from lp.registry.errors import NoSuchSourcePackageName
+from lp.registry.errors import (
+    NoSuchOCIProjectName,
+    NoSuchSourcePackageName,
+    )
 from lp.registry.interfaces.distribution import IDistribution
 from lp.registry.interfaces.distributionsourcepackage import (
     IDistributionSourcePackage,
     )
+from lp.registry.interfaces.ociproject import IOCIProject
 from lp.registry.interfaces.person import (
     IPerson,
     IPersonSet,
@@ -162,16 +166,24 @@ class DistributionGitTraversable(_BaseGitTraversable):
         """
         # Distributions don't support named repositories themselves, so
         # ignore the base traverse method.
-        if name != "+source":
+        if name not in {"+source", "+oci"}:
             raise InvalidNamespace("/".join(segments.traversed))
         try:
             spn_name = next(segments)
         except StopIteration:
             raise InvalidNamespace("/".join(segments.traversed))
-        distro_source_package = self.context.getSourcePackage(spn_name)
-        if distro_source_package is None:
-            raise NoSuchSourcePackageName(spn_name)
-        return owner, distro_source_package, None
+        if name == "+source":
+            distro_source_package = self.context.getSourcePackage(spn_name)
+            if distro_source_package is None:
+                raise NoSuchSourcePackageName(spn_name)
+            return owner, distro_source_package, None
+        elif name == "+oci":
+            oci_project = self.context.getOCIProject(spn_name)
+            if oci_project is None:
+                raise NoSuchOCIProjectName(spn_name)
+            return owner, oci_project, None
+        else:
+            raise AssertionError("name '%s' is not +source or +oci" % name)
 
 
 @adapter(IDistributionSourcePackage)
@@ -228,6 +240,21 @@ class PersonGitTraversable(_BaseGitTraversable):
             return owner, pillar, None
 
 
+@adapter(IOCIProject)
+@implementer(IGitTraversable)
+class DistributionOCIProjectGitTraversable(_BaseGitTraversable):
+    """Git repository traversable for distribution-based OCI Projects.
+
+    From here, you can traverse to a named distribution-based OCI Project
+    repository.
+    """
+
+    def getNamespace(self, owner):
+        return getUtility(IGitNamespaceSet).get(
+            owner, distribution=self.context.distribution,
+            ociprojectname=self.context.ociprojectname)
+
+
 class SegmentIterator:
     """An iterator that remembers the elements it has traversed."""
 
diff --git a/lib/lp/code/model/gitnamespace.py b/lib/lp/code/model/gitnamespace.py
index a255c5a..96ab8c0 100644
--- a/lib/lp/code/model/gitnamespace.py
+++ b/lib/lp/code/model/gitnamespace.py
@@ -5,8 +5,8 @@
 
 __metaclass__ = type
 __all__ = [
-    'DistributionOCIGitNamespace',
     'GitNamespaceSet',
+    'OCIProjectGitNamespace',
     'PackageGitNamespace',
     'PersonalGitNamespace',
     'ProjectGitNamespace',
@@ -543,11 +543,11 @@ class PackageGitNamespace(_BaseGitNamespace):
 
 
 @implementer(IGitNamespace, IGitNamespacePolicy)
-class DistributionOCIGitNamespace(_BaseGitNamespace):
+class OCIProjectGitNamespace(_BaseGitNamespace):
     """A namespace for OCI Project repositories.
 
     This namespace is for all the repositories owned by a particular person
-    in a particular OCI Project in a particular distribution.
+    in a particular OCI Project in a particular pillar.
     """
 
     has_defaults = True
@@ -558,11 +558,12 @@ class DistributionOCIGitNamespace(_BaseGitNamespace):
 
     def __init__(self, person, oci_project):
         self.owner = person
-        # Ensure we have a valid target for this namespace
-        assert oci_project.distribution is not None
         self.oci_project = oci_project
 
     def _getRepositoriesClause(self):
+        # XXX cjwatson 2019-11-25: This will eventually need project support,
+        # but assert that we have a distribution for now.
+        assert self.oci_project.distribution is not None
         return And(
             GitRepository.owner == self.owner,
             GitRepository.distribution == self.oci_project.distribution,
@@ -583,6 +584,7 @@ class DistributionOCIGitNamespace(_BaseGitNamespace):
 
     def _retargetRepository(self, repository):
         ocip = self.oci_project
+        # XXX cjwatson 2019-11-25: This will eventually need project support.
         repository.project = None
         repository.distribution = ocip.distribution
         repository.sourcepackagename = None
@@ -606,7 +608,7 @@ class DistributionOCIGitNamespace(_BaseGitNamespace):
             raise AssertionError(
                 "Namespace of %s is not %s." % (this.unique_name, self.name))
         other_namespace = other.namespace
-        if zope_isinstance(other_namespace, DistributionOCIGitNamespace):
+        if zope_isinstance(other_namespace, OCIProjectGitNamespace):
             return self.target == other_namespace.target
         else:
             return False
@@ -630,6 +632,8 @@ class GitNamespaceSet:
     def get(self, person, project=None, distribution=None,
             sourcepackagename=None, ociprojectname=None):
         """See `IGitNamespaceSet`."""
+        # XXX cjwatson 2019-11-25: This will eventually need project-based
+        # OCIProject support.
         if project is not None:
             assert (distribution is None and sourcepackagename is None
                     and ociprojectname is None), (
@@ -651,7 +655,7 @@ class GitNamespaceSet:
                 return PackageGitNamespace(
                     person, distribution.getSourcePackage(sourcepackagename))
             elif ociprojectname is not None:
-                return DistributionOCIGitNamespace(
+                return OCIProjectGitNamespace(
                     person, distribution.getOCIProject(ociprojectname.name))
             else:
                 raise AssertionError(
diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
index 92b6308..966db3a 100644
--- a/lib/lp/code/model/gitrepository.py
+++ b/lib/lp/code/model/gitrepository.py
@@ -1736,6 +1736,10 @@ class GitRepositorySet:
             clauses.append(GitRepository.distribution == target.distribution)
             clauses.append(
                 GitRepository.sourcepackagename == target.sourcepackagename)
+        elif IOCIProject.providedBy(target):
+            clauses.append(GitRepository.distribution == target.distribution)
+            clauses.append(
+                GitRepository.ociprojectname == target.ociprojectname)
         else:
             raise GitTargetError(
                 "Personal repositories cannot be defaults for any target.")
@@ -1758,8 +1762,12 @@ class GitRepositorySet:
                 "Personal repositories cannot be defaults for any target.")
         return IStore(GitRepository).find(GitRepository, *clauses).one()
 
-    def setDefaultRepository(self, target, repository):
+    def setDefaultRepository(self, target, repository, force_oci=False):
         """See `IGitRepositorySet`."""
+        if IOCIProject.providedBy(target) and not force_oci:
+            raise GitTargetError(
+                "Cannot manually set a default Git repository"
+                " for an OCI Project")
         if IPerson.providedBy(target):
             raise GitTargetError(
                 "Cannot set a default Git repository for a person, only "
diff --git a/lib/lp/code/model/tests/test_gitlookup.py b/lib/lp/code/model/tests/test_gitlookup.py
index f729ddd..2bb5773 100644
--- a/lib/lp/code/model/tests/test_gitlookup.py
+++ b/lib/lp/code/model/tests/test_gitlookup.py
@@ -19,7 +19,10 @@ from lp.code.interfaces.gitlookup import (
     IGitTraverser,
     )
 from lp.code.interfaces.gitrepository import IGitRepositorySet
-from lp.registry.errors import NoSuchSourcePackageName
+from lp.registry.errors import (
+    NoSuchOCIProjectName,
+    NoSuchSourcePackageName,
+    )
 from lp.registry.interfaces.person import NoSuchPerson
 from lp.registry.interfaces.product import (
     InvalidProductName,
@@ -107,6 +110,14 @@ class TestGetByUniqueName(TestCaseWithFactory):
         self.assertIsNone(self.lookup.getByUniqueName(
             repository.unique_name + "-nonexistent"))
 
+    def test_ociproject(self):
+        ociproject = self.factory.makeOCIProject()
+        repository = self.factory.makeGitRepository(target=ociproject)
+        self.assertEqual(
+            repository, self.lookup.getByUniqueName(repository.unique_name))
+        self.assertIsNone(self.lookup.getByUniqueName(
+            repository.unique_name + "-nonexistent"))
+
 
 class TestGetByPath(TestCaseWithFactory):
     """Test `IGitLookup.getByPath`."""
@@ -151,6 +162,21 @@ class TestGetByPath(TestCaseWithFactory):
         self.assertEqual(
             (repository, ""), self.lookup.getByPath(repository.unique_name))
 
+    def test_ociproject(self):
+        oci_project = self.factory.makeOCIProject()
+        repository = self.factory.makeGitRepository(target=oci_project)
+        self.assertEqual(
+            (repository, ""), self.lookup.getByPath(repository.unique_name))
+
+    def test_ociproject_default(self):
+        oci_project = self.factory.makeOCIProject()
+        repository = self.factory.makeGitRepository(target=oci_project)
+        with person_logged_in(repository.target.distribution.owner):
+            getUtility(IGitRepositorySet).setDefaultRepository(
+                repository.target, repository, force_oci=True)
+        self.assertEqual(
+            (repository, ""), self.lookup.getByPath(repository.shortened_path))
+
     def test_extra_path(self):
         repository = self.factory.makeGitRepository()
         self.assertEqual(
@@ -362,6 +388,38 @@ class TestGitTraverser(TestCaseWithFactory):
                 dsp.distribution.name, dsp.sourcepackagename.name,
                 repository.name))
 
+    def test_missing_ociprojectname(self):
+        # `traverse_path` raises `InvalidNamespace` if there are no segments
+        # after '+oci'.
+        self.factory.makeDistribution(name="distro")
+        self.assertRaises(
+            InvalidNamespace, self.traverser.traverse_path, "distro/+oci")
+
+    def test_no_such_ociprojectname(self):
+        # `traverse_path` raises `NoSuchOCIProjectName` if the package in
+        # distro/+oci/ociproject doesn't exist.
+        self.factory.makeDistribution(name="distro")
+        self.assertRaises(
+            NoSuchOCIProjectName, self.traverser.traverse_path,
+            "distro/+oci/nonexistent")
+
+    def test_ociproject(self):
+        # `traverse_path` resolves 'distro/+oci/ociproject' to the OCI
+        # project.
+        oci_project = self.factory.makeOCIProject()
+        path = "%s/+oci/%s" % (oci_project.pillar.name, oci_project.name)
+        self.assertTraverses(path, None, oci_project)
+
+    def test_ociproject_no_named_repositories(self):
+        # OCI projects do not have named repositories without an owner
+        # context, so trying to traverse to them raises `InvalidNamespace`.
+        oci_project = self.factory.makeOCIProject()
+        repository = self.factory.makeGitRepository(target=oci_project)
+        self.assertRaises(
+            InvalidNamespace, self.traverser.traverse_path,
+            "%s/+oci/%s/+git/%s" % (
+                oci_project.pillar.name, oci_project.name, repository.name))
+
     def test_nonexistent_person(self):
         # `traverse_path` raises `NoSuchPerson` when resolving a path of
         # '~person/project' if the person doesn't exist.
@@ -521,6 +579,65 @@ class TestGitTraverser(TestCaseWithFactory):
                 repository.name),
             person, dsp, repository)
 
+    def test_missing_person_ociprojectname(self):
+        # `traverse_path` raises `InvalidNamespace` if there are no segments
+        # after '+oci' in a person-OCIProject path.
+        self.factory.makePerson(name="person")
+        self.factory.makeDistribution(name="distro")
+        self.assertRaises(
+            InvalidNamespace, self.traverser.traverse_path,
+            "~person/distro/+oci")
+
+    def test_no_such_person_ociprojectname(self):
+        # `traverse_path` raises `NoSuchOCIProjectName` if the package in
+        # ~person/distro/+oci/ociproject doesn't exist.
+        self.factory.makePerson(name="person")
+        self.factory.makeDistribution(name="distro")
+        self.assertRaises(
+            NoSuchOCIProjectName, self.traverser.traverse_path,
+            "~person/distro/+oci/nonexistent")
+
+    def test_person_ociproject(self):
+        # `traverse_path` resolves '~person/distro/+oci/ociproject' to the
+        # person and the OCIProject.
+        person = self.factory.makePerson()
+        oci_project = self.factory.makeOCIProject()
+        path = "~%s/%s/+oci/%s" % (
+            person.name, oci_project.pillar.name, oci_project.name)
+        self.assertTraverses(path, person, oci_project)
+
+    def test_person_ociproject_missing_repository_name(self):
+        # `traverse_path` raises `InvalidNamespace` if there are no segments
+        # after '+git'.
+        person = self.factory.makePerson()
+        oci_project = self.factory.makeOCIProject()
+        self.assertRaises(
+            InvalidNamespace, self.traverser.traverse_path,
+            "~%s/%s/+oci/%s/+git" % (
+                person.name, oci_project.pillar.name, oci_project.name))
+
+    def test_person_ociproject_no_such_repository(self):
+        # `traverse_path` raises `NoSuchGitRepository` if the repository in
+        # ~person/distro/+oci/ociproject/+git/repository doesn't exist.
+        person = self.factory.makePerson()
+        oci_project = self.factory.makeOCIProject()
+        self.assertRaises(
+            NoSuchGitRepository, self.traverser.traverse_path,
+            "~%s/%s/+oci/%s/+git/nonexistent" % (
+                person.name, oci_project.pillar.name, oci_project.name))
+
+    def test_person_ociproject_repository(self):
+        # `traverse_path` resolves an existing person-OCIProject repository.
+        person = self.factory.makePerson()
+        oci_project = self.factory.makeOCIProject()
+        repository = self.factory.makeGitRepository(
+            owner=person, target=oci_project)
+        self.assertTraverses(
+            "~%s/%s/+oci/%s/+git/%s" % (
+                person.name, oci_project.pillar.name, oci_project.name,
+                repository.name),
+            person, oci_project, repository)
+
     def test_person_repository_from_person(self):
         # To save on queries, `traverse` can be given a person as a starting
         # point for the traversal.
diff --git a/lib/lp/code/model/tests/test_gitnamespace.py b/lib/lp/code/model/tests/test_gitnamespace.py
index 4f18523..7585e71 100644
--- a/lib/lp/code/model/tests/test_gitnamespace.py
+++ b/lib/lp/code/model/tests/test_gitnamespace.py
@@ -31,7 +31,7 @@ from lp.code.interfaces.gitnamespace import (
     )
 from lp.code.interfaces.gitrepository import IGitRepositorySet
 from lp.code.model.gitnamespace import (
-    DistributionOCIGitNamespace,
+    OCIProjectGitNamespace,
     PackageGitNamespace,
     PersonalGitNamespace,
     ProjectGitNamespace,
@@ -455,8 +455,8 @@ class TestProjectGitNamespace(TestCaseWithFactory, NamespaceMixin):
             repositories[0].namespace.collection.getRepositories())
 
 
-class TestDistributionOCIGitNamespace(TestCaseWithFactory, NamespaceMixin):
-    """Tests for `DistributionOCIGitNamespace`."""
+class TestOCIProjectGitNamespace(TestCaseWithFactory, NamespaceMixin):
+    """Tests for `OCIProjectGitNamespace`."""
 
     layer = DatabaseFunctionalLayer
 
@@ -468,7 +468,7 @@ class TestDistributionOCIGitNamespace(TestCaseWithFactory, NamespaceMixin):
     def test_name(self):
         person = self.factory.makePerson()
         oci_project = self.factory.makeOCIProject()
-        namespace = DistributionOCIGitNamespace(person, oci_project)
+        namespace = OCIProjectGitNamespace(person, oci_project)
         self.assertEqual(
             "~%s/%s/+oci/%s" % (
                 person.name, oci_project.distribution.name,
@@ -479,14 +479,14 @@ class TestDistributionOCIGitNamespace(TestCaseWithFactory, NamespaceMixin):
         # The person passed to an oci project namespace is the owner.
         person = self.factory.makePerson()
         oci_project = self.factory.makeOCIProject()
-        namespace = DistributionOCIGitNamespace(person, oci_project)
+        namespace = OCIProjectGitNamespace(person, oci_project)
         self.assertEqual(person, removeSecurityProxy(namespace).owner)
 
     def test_target(self):
         # The target for an oci project namespace is the oci project.
         person = self.factory.makePerson()
         oci_project = self.factory.makeOCIProject()
-        namespace = DistributionOCIGitNamespace(person, oci_project)
+        namespace = OCIProjectGitNamespace(person, oci_project)
         self.assertEqual(oci_project, namespace.target)
 
     def test_supports_merge_proposals(self):
diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py
index 1577038..aed13a0 100644
--- a/lib/lp/code/model/tests/test_gitrepository.py
+++ b/lib/lp/code/model/tests/test_gitrepository.py
@@ -567,6 +567,18 @@ class TestGitIdentityMixin(TestCaseWithFactory):
             "%s/+source/%s" % (
                 dsp.distribution.name, dsp.sourcepackagename.name))
 
+    def test_git_identity_default_for_oci_project(self):
+        # If a repository is the default for an OCI project, then its Git
+        # identity uses the path to that OCI project.
+        oci_project = self.factory.makeOCIProject()
+        repository = self.factory.makeGitRepository(target=oci_project)
+        with admin_logged_in():
+            self.repository_set.setDefaultRepository(
+                oci_project, repository, force_oci=True)
+        self.assertGitIdentity(
+            repository,
+            "%s/+oci/%s" % (oci_project.pillar.name, oci_project.name))
+
     def test_git_identity_owner_default_for_project(self):
         # If a repository is a person's default for a project, then its Git
         # identity is a combination of the person and project names.
@@ -649,6 +661,27 @@ class TestGitIdentityMixin(TestCaseWithFactory):
              ("~eric/mint/+source/choc/+git/choc-repo", repository)],
             repository.getRepositoryIdentities())
 
+    def test_default_for_oci_project(self):
+        # If a repository is the default for an OCI project, then that is
+        # the preferred identity.
+        mint = self.factory.makeDistribution(name="mint")
+        eric = self.factory.makePerson(name="eric")
+        mint_choc = self.factory.makeOCIProject(
+            pillar=mint, ociprojectname="choc")
+        repository = self.factory.makeGitRepository(
+            owner=eric, target=mint_choc, name="choc-repo")
+        oci_project = repository.target
+        with admin_logged_in():
+            self.repository_set.setDefaultRepository(
+                oci_project, repository, force_oci=True)
+        self.assertEqual(
+            [ICanHasDefaultGitRepository(oci_project)],
+            repository.getRepositoryDefaults())
+        self.assertEqual(
+            [("mint/+oci/choc", oci_project),
+             ("~eric/mint/+oci/choc/+git/choc-repo", repository)],
+            repository.getRepositoryIdentities())
+
 
 class TestGitRepositoryDeletion(TestCaseWithFactory):
     """Test the different cases that make a repository deletable or not."""
@@ -3224,6 +3257,29 @@ class TestGitRepositorySet(TestCaseWithFactory):
                 self.repository_set.setDefaultRepositoryForOwner,
                 person, person, repository, user)
 
+    def test_setDefaultRepository_refuses_oci_project(self):
+        # setDefaultRepository refuses if the target is an OCI project.
+        oci_project = self.factory.makeOCIProject()
+        repository = self.factory.makeGitRepository(target=oci_project)
+        with admin_logged_in():
+            self.assertRaises(
+                GitTargetError, self.repository_set.setDefaultRepository,
+                oci_project, repository)
+
+    def test_setDefaultRepository_accepts_oci_project_override(self):
+        # setDefaultRepository refuses if the target is an OCI project.
+        oci_project = self.factory.makeOCIProject()
+        repository = self.factory.makeGitRepository(target=oci_project)
+        with admin_logged_in():
+            self.repository_set.setDefaultRepository(
+                oci_project, repository, force_oci=True)
+        identity_path = "%s/+oci/%s" % (
+                oci_project.distribution.name, oci_project.name)
+        self.assertEqual(
+            identity_path, repository.shortened_path, "shortened path")
+        self.assertEqual(
+            "lp:%s" % identity_path, repository.git_identity, "git identity")
+
     def test_setDefaultRepositoryForOwner_noop(self):
         # If a repository is already the target owner default, setting
         # the default again should no-op.
@@ -3370,6 +3426,26 @@ class TestGitRepositorySetDefaultsPackage(
         return target.distribution.owner
 
 
+class TestGitRepositorySetDefaultsOCIProject(
+    TestGitRepositorySetDefaultsMixin, TestCaseWithFactory):
+
+    def setUp(self):
+        super(TestGitRepositorySetDefaultsOCIProject, self).setUp()
+        self.set_method = (lambda target, repository, user:
+            self.repository_set.setDefaultRepository(
+                target, repository, force_oci=True))
+
+    def makeTarget(self, template=None):
+        kwargs = {}
+        if template is not None:
+            kwargs["pillar"] = template.pillar
+        return self.factory.makeOCIProject(**kwargs)
+
+    @staticmethod
+    def getPersonForLogin(target):
+        return target.pillar.owner
+
+
 class TestGitRepositorySetDefaultsOwnerMixin(
     TestGitRepositorySetDefaultsMixin):