← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~pappacena/launchpad:git-repo-async-privacy-virtualrefs into launchpad:master

 

Thiago F. Pappacena has proposed merging ~pappacena/launchpad:git-repo-async-privacy-virtualrefs into launchpad:master.

Commit message:
Doing virtual refs sync when running git repository privacy change background task

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

This branch carries code from https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/390581, but it does a big chunch of refactoring of the code contained there. Mostly, the bulk operations got encapsulated in the GitHostingClient, so we would have a single point of change once Turnip will be able to run the copy/delete refs operations in bulk (instead of doing a bunch of requests).
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/launchpad:git-repo-async-privacy-virtualrefs into launchpad:master.
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index 11aba6f..94cb548 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -2082,6 +2082,24 @@ public.webhookjob                       = SELECT, INSERT
 public.xref                             = SELECT, INSERT, DELETE
 type=user
 
+[privacy-change-jobs]
+groups=script, branchscanner
+public.accessartifact                   = SELECT, UPDATE, DELETE, INSERT
+public.accessartifactgrant              = SELECT, UPDATE, DELETE, INSERT
+public.accesspolicyartifact             = SELECT, UPDATE, DELETE, INSERT
+public.accesspolicygrant                = SELECT, UPDATE, DELETE
+public.account                          = SELECT
+public.distribution                     = SELECT
+public.gitjob                           = SELECT, UPDATE
+public.gitrepository                    = SELECT, UPDATE
+public.gitsubscription                  = SELECT, UPDATE, DELETE
+public.job                              = SELECT, INSERT, UPDATE
+public.person                           = SELECT
+public.product                          = SELECT
+public.sharingjob                       = SELECT, INSERT, UPDATE
+public.teamparticipation                = SELECT
+type=user
+
 [sharing-jobs]
 groups=script
 public.accessartifactgrant              = SELECT, UPDATE, DELETE
diff --git a/lib/lp/app/widgets/itemswidgets.py b/lib/lp/app/widgets/itemswidgets.py
index 1dbb59f..a644a96 100644
--- a/lib/lp/app/widgets/itemswidgets.py
+++ b/lib/lp/app/widgets/itemswidgets.py
@@ -189,25 +189,29 @@ class LaunchpadRadioWidgetWithDescription(LaunchpadRadioWidget):
         """Render an item of the list."""
         text = html_escape(text)
         id = '%s.%s' % (name, index)
+        extra_attr = {"disabled": "disabled"} if self.context.readonly else {}
         elem = renderElement(u'input',
                              value=value,
                              name=name,
                              id=id,
                              cssClass=cssClass,
-                             type='radio')
+                             type='radio',
+                             **extra_attr)
         return self._renderRow(text, value, id, elem)
 
     def renderSelectedItem(self, index, text, value, name, cssClass):
         """Render a selected item of the list."""
         text = html_escape(text)
         id = '%s.%s' % (name, index)
+        extra_attr = {"disabled": "disabled"} if self.context.readonly else {}
         elem = renderElement(u'input',
                              value=value,
                              name=name,
                              id=id,
                              cssClass=cssClass,
                              checked="checked",
-                             type='radio')
+                             type='radio',
+                             **extra_attr)
         return self._renderRow(text, value, id, elem)
 
     def renderExtraHint(self):
diff --git a/lib/lp/code/browser/gitrepository.py b/lib/lp/code/browser/gitrepository.py
index 2552ffb..029e7ba 100644
--- a/lib/lp/code/browser/gitrepository.py
+++ b/lib/lp/code/browser/gitrepository.py
@@ -483,6 +483,9 @@ class GitRepositoryView(InformationTypePortletMixin, LaunchpadView,
     def warning_message(self):
         if self.context.status == GitRepositoryStatus.CREATING:
             return "This repository is being created."
+        if (self.context.status ==
+                GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION):
+            return "This repository's information type is being changed."
         return None
 
     @property
@@ -573,6 +576,9 @@ class GitRepositoryEditFormView(LaunchpadEditFormView):
     @cachedproperty
     def schema(self):
         info_types = self.getInformationTypesToShow()
+        read_only_info_type = (
+                self.context.status ==
+                GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION)
 
         class GitRepositoryEditSchema(Interface):
             """Defines the fields for the edit form.
@@ -582,7 +588,8 @@ class GitRepositoryEditFormView(LaunchpadEditFormView):
             """
             use_template(IGitRepository, include=["default_branch"])
             information_type = copy_field(
-                IGitRepository["information_type"], readonly=False,
+                IGitRepository["information_type"],
+                readonly=read_only_info_type,
                 vocabulary=InformationTypeVocabulary(types=info_types))
             name = copy_field(IGitRepository["name"], readonly=False)
             owner = copy_field(IGitRepository["owner"], readonly=False)
@@ -785,6 +792,11 @@ class GitRepositoryEditView(CodeEditOwnerMixin, GitRepositoryEditFormView):
             self.widgets["target"].hint = (
                 "This is the default repository for this target, so it "
                 "cannot be moved to another target.")
+        if (self.context.status ==
+                GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION):
+            self.widgets["information_type"].hint = (
+                "Information type is being changed. The operation needs to "
+                "finish before you can changing it again.")
         if self.context.default_branch:
             self.widgets['default_branch'].context.required = True
 
diff --git a/lib/lp/code/browser/tests/test_branchmergeproposal.py b/lib/lp/code/browser/tests/test_branchmergeproposal.py
index f2ec4ec..83c51dc 100644
--- a/lib/lp/code/browser/tests/test_branchmergeproposal.py
+++ b/lib/lp/code/browser/tests/test_branchmergeproposal.py
@@ -2336,7 +2336,7 @@ class TestLatestProposalsForEachBranchGit(
 
     @staticmethod
     def _setBranchInvisible(branch):
-        removeSecurityProxy(branch.repository).transitionToInformationType(
+        removeSecurityProxy(branch.repository)._transitionToInformationType(
             InformationType.USERDATA, branch.owner, verify_policy=False)
 
 
diff --git a/lib/lp/code/browser/tests/test_gitrepository.py b/lib/lp/code/browser/tests/test_gitrepository.py
index 912965d..8a9af9f 100644
--- a/lib/lp/code/browser/tests/test_gitrepository.py
+++ b/lib/lp/code/browser/tests/test_gitrepository.py
@@ -60,6 +60,9 @@ from lp.code.enums import (
     GitRepositoryType,
     )
 from lp.code.interfaces.gitcollection import IGitCollection
+from lp.code.interfaces.gitjob import (
+    IGitRepositoryTransitionToInformationTypeJobSource,
+    )
 from lp.code.interfaces.gitrepository import IGitRepositorySet
 from lp.code.interfaces.revision import IRevisionSet
 from lp.code.model.gitjob import GitRefScanJob
@@ -161,6 +164,15 @@ class TestGitRepositoryView(BrowserTestCase):
         self.assertTextMatchesExpressionIgnoreWhitespace(
             r"""This repository is being created\..*""", text)
 
+    def test_changing_info_type_warning_message_is_present(self):
+        repository = removeSecurityProxy(self.factory.makeGitRepository())
+        repository.status = (
+            GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION)
+        text = self.getMainText(repository, "+index", user=repository.owner)
+        self.assertTextMatchesExpressionIgnoreWhitespace(
+            r"""This repository's information type is being changed\..*""",
+            text)
+
     def test_creating_warning_message_is_not_shown(self):
         repository = removeSecurityProxy(self.factory.makeGitRepository())
         repository.status = GitRepositoryStatus.AVAILABLE
@@ -1161,7 +1173,50 @@ class TestGitRepositoryEditView(TestCaseWithFactory):
         browser.getControl("Change Git Repository").click()
         with person_logged_in(person):
             self.assertEqual(
-                InformationType.USERDATA, repository.information_type)
+                GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION,
+                repository.status)
+            job_util = getUtility(
+                IGitRepositoryTransitionToInformationTypeJobSource)
+            jobs = list(job_util.iterReady())
+            self.assertEqual(1, len(jobs))
+            job = removeSecurityProxy(jobs[0])
+            self.assertEqual(repository, job.repository)
+            self.assertEqual(InformationType.USERDATA, job.information_type)
+            self.assertEqual(admin, job.user)
+
+    def test_information_type_in_ui_blocked_if_already_changing(self):
+        # The information_type of a repository can't be changed via the UI
+        # if the repository is already pending a info type change.
+        person = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(owner=person)
+        removeSecurityProxy(repository).status = (
+            GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION)
+        admin = getUtility(ILaunchpadCelebrities).admin.teamowner
+        browser = self.getUserBrowser(
+            canonical_url(repository) + "/+edit", user=admin)
+        # Make sure the privacy controls are all disabled in the UI.
+        controls = [
+            "Public", "Public Security", "Private Security", "Private",
+            "Proprietary", "Embargoed"]
+        self.assertTrue(
+            all(browser.getControl(i, index=0).disabled for i in controls))
+        expected_msg = (
+            "Information type is being changed. The operation needs to "
+            "finish before you can changing it again.")
+        self.assertIn(expected_msg, extract_text(browser.contents))
+
+        # Trying to change should have no effect in the backend, since the
+        # repository is already changing info type and this field is read-only.
+        browser.getControl("Private", index=1).click()
+        browser.getControl("Change Git Repository").click()
+        with person_logged_in(person):
+            self.assertEqual(
+                GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION,
+                repository.status)
+            job_util = getUtility(
+                IGitRepositoryTransitionToInformationTypeJobSource)
+            jobs = list(job_util.iterReady())
+            self.assertEqual(0, len(jobs))
 
     def test_edit_view_ajax_render(self):
         # An information type change request is processed as expected when
@@ -1184,7 +1239,17 @@ class TestGitRepositoryEditView(TestCaseWithFactory):
             result = view.render()
             self.assertEqual("", result)
             self.assertEqual(
-                repository.information_type, InformationType.PUBLICSECURITY)
+                GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION,
+                repository.status)
+            job_util = getUtility(
+                IGitRepositoryTransitionToInformationTypeJobSource)
+            jobs = list(job_util.iterReady())
+            self.assertEqual(1, len(jobs))
+            job = removeSecurityProxy(jobs[0])
+            self.assertEqual(repository, job.repository)
+            self.assertEqual(
+                InformationType.PUBLICSECURITY, job.information_type)
+            self.assertEqual(person, job.user)
 
     def test_change_default_branch(self):
         # An authorised user can change the default branch to one that
diff --git a/lib/lp/code/configure.zcml b/lib/lp/code/configure.zcml
index 898e645..fee38a9 100644
--- a/lib/lp/code/configure.zcml
+++ b/lib/lp/code/configure.zcml
@@ -1111,6 +1111,11 @@
       provides="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJobSource">
     <allow interface="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJobSource" />
   </securedutility>
+  <securedutility
+      component="lp.code.model.gitjob.GitRepositoryTransitionToInformationTypeJob"
+      provides="lp.code.interfaces.gitjob.IGitRepositoryTransitionToInformationTypeJobSource">
+    <allow interface="lp.code.interfaces.gitjob.IGitRepositoryTransitionToInformationTypeJobSource" />
+  </securedutility>
   <class class="lp.code.model.gitjob.GitRefScanJob">
     <allow interface="lp.code.interfaces.gitjob.IGitJob" />
     <allow interface="lp.code.interfaces.gitjob.IGitRefScanJob" />
@@ -1123,6 +1128,10 @@
     <allow interface="lp.code.interfaces.gitjob.IGitJob" />
     <allow interface="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJob" />
   </class>
+  <class class="lp.code.model.gitjob.GitRepositoryTransitionToInformationTypeJob">
+    <allow interface="lp.code.interfaces.gitjob.IGitJob" />
+    <allow interface="lp.code.interfaces.gitjob.IGitRepositoryTransitionToInformationTypeJob" />
+  </class>
 
   <lp:help-folder folder="help" name="+help-code" />
 
diff --git a/lib/lp/code/enums.py b/lib/lp/code/enums.py
index d2a9262..2edf401 100644
--- a/lib/lp/code/enums.py
+++ b/lib/lp/code/enums.py
@@ -167,6 +167,12 @@ class GitRepositoryStatus(DBEnumeratedType):
         This repository is available to be used.
         """)
 
+    PENDING_INFORMATION_TYPE_TRANSITION = DBItem(3, """
+        Information type transition pending
+        
+        This repository's privacy setting is being changed.
+    """)
+
 
 class GitObjectType(DBEnumeratedType):
     """Git Object Type
diff --git a/lib/lp/code/interfaces/branchmergeproposal.py b/lib/lp/code/interfaces/branchmergeproposal.py
index bf481c9..efe1e4a 100644
--- a/lib/lp/code/interfaces/branchmergeproposal.py
+++ b/lib/lp/code/interfaces/branchmergeproposal.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """The interface for branch merge proposals."""
@@ -519,6 +519,50 @@ class IBranchMergeProposalView(Interface):
     def getLatestDiffUpdateJob():
         """Return the latest IUpdatePreviewDiffJob for this MP."""
 
+    def getGitRefCopyOperations():
+        """Gets the list of GitHosting copy operations that should be done
+        in order to keep this MP's virtual refs up-to-date.
+
+        Note that, for now, the only possible virtual ref that could
+        possibly be copied is GIT_CREATE_MP_VIRTUAL_REF. So, this method
+        will return at most 1 copy operation. Once new virtual refs will be
+        created, this method should be extended to add more copy operations
+        too.
+
+        :return: A list of RefCopyOperation objects.
+        """
+
+    def getGitRefDeleteOperations(force_delete=False):
+        """Gets the list of git refs that should be deleted in order to keep
+        this MP's virtual refs up-to-date.
+
+        Note that, for now, the only existing virtual ref to be deleted is
+        GIT_CREATE_MP_VIRTUAL_REF. So this method will only ever delete at
+        most 1 virtual ref. Once new virtual refs will be created, this method
+        should be extended to delete them also.
+
+        :param force_delete: True if we should get delete operation
+            regardless of any check. False otherwise.
+        :return: A list of ref names.
+        """
+
+    def syncGitVirtualRefs(force_delete=False):
+        """Requests all copies and deletion of virtual refs to make git code
+        hosting in sync with this MP.
+
+        :param force_delete: Do not try to copy any new virtual ref. Just
+            delete all virtual refs possibly created.
+        """
+
+    def bulkSyncGitVirtualRefs(merge_proposals):
+        """Synchronizes a set of merge proposals' virtual refs.
+
+        :params merge_proposals: The set of merge proposals that should have
+            the virtual refs synced.
+        :return: A tuple with the list of (copy operations, delete operations)
+            executed.
+        """
+
 
 class IBranchMergeProposalEdit(Interface):
 
diff --git a/lib/lp/code/interfaces/githosting.py b/lib/lp/code/interfaces/githosting.py
index 441e850..7fe0d0e 100644
--- a/lib/lp/code/interfaces/githosting.py
+++ b/lib/lp/code/interfaces/githosting.py
@@ -148,3 +148,11 @@ class IGitHostingClient(Interface):
         :param refs: A list of tuples like (repo_path, ref_name) to be deleted.
         :param logger: An optional logger.
         """
+
+    def bulkSyncRefs(copy_operations, delete_operations):
+        """Executes all copy operations and delete operations on Turnip
+        side.
+
+        :param copy_operations: The list of RefCopyOperation to run.
+        :param delete_operations: The list of RefDeleteOperation to run.
+        """
diff --git a/lib/lp/code/interfaces/gitjob.py b/lib/lp/code/interfaces/gitjob.py
index 4f31b19..bfe5525 100644
--- a/lib/lp/code/interfaces/gitjob.py
+++ b/lib/lp/code/interfaces/gitjob.py
@@ -1,4 +1,4 @@
-# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """GitJob interfaces."""
@@ -11,6 +11,8 @@ __all__ = [
     'IGitRefScanJobSource',
     'IGitRepositoryModifiedMailJob',
     'IGitRepositoryModifiedMailJobSource',
+    'IGitRepositoryTransitionToInformationTypeJob',
+    'IGitRepositoryTransitionToInformationTypeJobSource',
     'IReclaimGitRepositorySpaceJob',
     'IReclaimGitRepositorySpaceJobSource',
     ]
@@ -93,3 +95,20 @@ class IGitRepositoryModifiedMailJobSource(IJobSource):
         :param repository_delta: An `IGitRepositoryDelta` describing the
             changes.
         """
+
+
+class IGitRepositoryTransitionToInformationTypeJob(IRunnableJob):
+    """A Job to change repository's information type."""
+
+
+class IGitRepositoryTransitionToInformationTypeJobSource(IJobSource):
+
+    def create(repository, user, information_type, verify_policy=True):
+        """Create a job to change git repository's information type.
+
+        :param repository: The `IGitRepository` that was modified.
+        :param information_type: The `InformationType` to transition to.
+        :param user: The `IPerson` who is making the change.
+        :param verify_policy: Check if the new information type complies
+            with the `IGitNamespacePolicy`.
+        """
diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py
index 192133a..48e7dd3 100644
--- a/lib/lp/code/interfaces/gitrepository.py
+++ b/lib/lp/code/interfaces/gitrepository.py
@@ -8,6 +8,8 @@ __metaclass__ = type
 __all__ = [
     'ContributorGitIdentity',
     'GitIdentityMixin',
+    'GIT_CREATE_MP_VIRTUAL_REF',
+    'GIT_MP_VIRTUAL_REF_FORMAT',
     'GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE',
     'git_repository_name_validator',
     'IGitRepository',
@@ -105,6 +107,11 @@ valid_git_repository_name_pattern = re.compile(
     r"^(?i)[a-z0-9][a-z0-9+\.\-@_]*\Z")
 
 
+# Virtual ref where we automatically put a copy of any open merge proposal.
+GIT_MP_VIRTUAL_REF_FORMAT = 'refs/merge/{mp.id}/head'
+GIT_CREATE_MP_VIRTUAL_REF = 'git.mergeproposal_virtualref.enabled'
+
+
 def valid_git_repository_name(name):
     """Return True iff the name is valid as a Git repository name.
 
@@ -595,11 +602,15 @@ class IGitRepositoryView(IHasRecipes):
     def updateLandingTargets(paths):
         """Update landing targets (MPs where this repository is the source).
 
-        For each merge proposal, create `UpdatePreviewDiffJob`s.
+        For each merge proposal, create `UpdatePreviewDiffJob`s, and runs
+        the appropriate GitHosting.copyRefs operation to allow having
+        virtual merge refs with the updated branch version.
 
         :param paths: A list of reference paths.  Any merge proposals whose
             source is this repository and one of these paths will have their
             diffs updated.
+        :return: Returns a tuple with the list of background jobs created,
+                 and the list of ref copies requested to GitHosting.
         """
 
     def markRecipesStale(paths):
diff --git a/lib/lp/code/model/branchmergeproposal.py b/lib/lp/code/model/branchmergeproposal.py
index 061fb1e..821b15d 100644
--- a/lib/lp/code/model/branchmergeproposal.py
+++ b/lib/lp/code/model/branchmergeproposal.py
@@ -12,6 +12,7 @@ __all__ = [
 
 from collections import defaultdict
 from email.utils import make_msgid
+import logging
 from operator import attrgetter
 import sys
 
@@ -84,7 +85,12 @@ from lp.code.interfaces.codereviewcomment import ICodeReviewComment
 from lp.code.interfaces.codereviewinlinecomment import (
     ICodeReviewInlineCommentSet,
     )
+from lp.code.interfaces.githosting import IGitHostingClient
 from lp.code.interfaces.gitref import IGitRef
+from lp.code.interfaces.gitrepository import (
+    GIT_CREATE_MP_VIRTUAL_REF,
+    GIT_MP_VIRTUAL_REF_FORMAT,
+    )
 from lp.code.mail.branch import RecipientReason
 from lp.code.model.branchrevision import BranchRevision
 from lp.code.model.codereviewcomment import CodeReviewComment
@@ -94,6 +100,10 @@ from lp.code.model.diff import (
     IncrementalDiff,
     PreviewDiff,
     )
+from lp.code.model.githosting import (
+    RefCopyOperation,
+    RefDeleteOperation,
+    )
 from lp.registry.interfaces.person import (
     IPerson,
     IPersonSet,
@@ -123,6 +133,7 @@ from lp.services.database.sqlbase import (
     quote,
     SQLBase,
     )
+from lp.services.features import getFeatureFlag
 from lp.services.helpers import shortlist
 from lp.services.job.interfaces.job import JobStatus
 from lp.services.job.model.job import Job
@@ -144,6 +155,9 @@ from lp.soyuz.enums import (
     )
 
 
+logger = logging.getLogger(__name__)
+
+
 def is_valid_transition(proposal, from_state, next_state, user=None):
     """Is it valid for this user to move this proposal to to next_state?
 
@@ -971,6 +985,7 @@ class BranchMergeProposal(SQLBase, BugLinkTargetMixin):
             branch_merge_proposal=self.id):
             job.destroySelf()
         self._preview_diffs.remove()
+
         self.destroySelf()
 
     def getUnlandedSourceBranchRevisions(self):
@@ -1217,6 +1232,89 @@ class BranchMergeProposal(SQLBase, BugLinkTargetMixin):
             for diff in diffs)
         return [diff_dict.get(revisions) for revisions in revision_list]
 
+    @property
+    def should_have_git_virtual_refs(self):
+        """True if this MP should have virtual refs in the target
+        repository. False otherwise."""
+        # If the source repository is private and it's opening a MP to
+        # another repository, we should not risk disclosing the private code to
+        # everyone with permission to see the target repo:
+        private_source = self.source_git_repository.private
+        same_owner = self.source_git_repository.owner.inTeam(
+            self.target_git_repository.owner)
+        return not private_source or same_owner
+
+    def getGitRefCopyOperations(self):
+        """See `IBranchMergeProposal`"""
+        if (not self.target_git_repository
+                or not getFeatureFlag(GIT_CREATE_MP_VIRTUAL_REF)):
+            # Not a git MP. Skip.
+            return []
+        if not self.should_have_git_virtual_refs:
+            return []
+        return [RefCopyOperation(
+            self.source_git_repository.getInternalPath(),
+            self.source_git_commit_sha1,
+            self.target_git_repository.getInternalPath(),
+            GIT_MP_VIRTUAL_REF_FORMAT.format(mp=self))]
+
+    def getGitRefDeleteOperations(self, force_delete=False):
+        """See `IBranchMergeProposal`"""
+        if self.source_git_ref is None:
+            # Not a git MP. Skip.
+            return []
+        if not force_delete and self.should_have_git_virtual_refs:
+            return []
+        return [RefDeleteOperation(
+            self.target_git_repository.getInternalPath(),
+            GIT_MP_VIRTUAL_REF_FORMAT.format(mp=self))]
+
+    def syncGitVirtualRefs(self, force_delete=False):
+        """See `IBranchMergeProposal`"""
+        if self.source_git_ref is None:
+            return
+        if not getFeatureFlag(GIT_CREATE_MP_VIRTUAL_REF):
+            # XXX pappacena 2020-09-15: Do not try to remove virtual refs if
+            # the feature is disabled. It might be disabled due to bug on
+            # the first versions, for example, and we should have a way to
+            # skip this feature entirely.
+            # Once the feature is stable, we should actually *always* delete
+            # the virtual refs, since we don't know if the feature was
+            # enabled or not when the branch was created.
+            return
+        if force_delete:
+            copy_operations = []
+        else:
+            copy_operations = self.getGitRefCopyOperations()
+        delete_operations = self.getGitRefDeleteOperations(force_delete)
+
+        if copy_operations or delete_operations:
+            hosting_client = getUtility(IGitHostingClient)
+            hosting_client.bulkSyncRefs(copy_operations, delete_operations)
+
+    @classmethod
+    def bulkSyncGitVirtualRefs(cls, merge_proposals):
+        """See `IBranchMergeProposal`"""
+        if not getFeatureFlag(GIT_CREATE_MP_VIRTUAL_REF):
+            # XXX pappacena 2020-09-15: Do not try to remove virtual refs if
+            # the feature is disabled. It might be disabled due to bug on
+            # the first versions, for example, and we should have a way to
+            # skip this feature entirely.
+            # Once the feature is stable, we should actually *always* delete
+            # the virtual refs, since we don't know if the feature was
+            # enabled or not when the branch was created.
+            return [], []
+        copy_operations = []
+        delete_operations = []
+        for merge_proposal in merge_proposals:
+            copy_operations += merge_proposal.getGitRefCopyOperations()
+            delete_operations += merge_proposal.getGitRefDeleteOperations()
+
+        if copy_operations or delete_operations:
+            hosting_client = getUtility(IGitHostingClient)
+            hosting_client.bulkSyncRefs(copy_operations, delete_operations)
+        return copy_operations, delete_operations
+
     def scheduleDiffUpdates(self, return_jobs=True):
         """See `IBranchMergeProposal`."""
         from lp.code.model.branchmergeproposaljob import (
diff --git a/lib/lp/code/model/githosting.py b/lib/lp/code/model/githosting.py
index 74f4ec0..b2632f6 100644
--- a/lib/lp/code/model/githosting.py
+++ b/lib/lp/code/model/githosting.py
@@ -7,9 +7,11 @@ __metaclass__ = type
 __all__ = [
     'GitHostingClient',
     'RefCopyOperation',
+    'RefDeleteOperation',
     ]
 
 import base64
+from collections import defaultdict
 import json
 import sys
 
@@ -33,7 +35,6 @@ from lp.code.errors import (
     GitRepositoryDeletionFault,
     GitRepositoryScanFault,
     GitTargetError,
-    NoSuchGitReference,
     )
 from lp.code.interfaces.githosting import IGitHostingClient
 from lp.services.config import config
@@ -55,12 +56,21 @@ class RefCopyOperation:
     This class is just a helper to define copy operations parameters on
     IGitHostingClient.copyRefs method.
     """
-    def __init__(self, source_ref, target_repo, target_ref):
+    def __init__(self, source_repo_path, source_ref, target_repo_path,
+                 target_ref):
+        self.source_repo_path = source_repo_path
         self.source_ref = source_ref
-        self.target_repo = target_repo
+        self.target_repo_path = target_repo_path
         self.target_ref = target_ref
 
 
+class RefDeleteOperation:
+    """A description of a ref delete operation."""
+    def __init__(self, repo_path, ref):
+        self.repo_path = repo_path
+        self.ref = ref
+
+
 @implementer(IGitHostingClient)
 class GitHostingClient:
     """A client for the internal API provided by the Git hosting system."""
@@ -277,7 +287,7 @@ class GitHostingClient:
         json_data = {
             "operations": [{
                 "from": i.source_ref,
-                "to": {"repo": i.target_repo, "ref": i.target_ref}
+                "to": {"repo": i.target_repo_path, "ref": i.target_ref}
             } for i in operations]
         }
         try:
@@ -299,6 +309,8 @@ class GitHostingClient:
 
     def deleteRefs(self, refs, logger=None):
         """See `IGitHostingClient`."""
+        # XXX pappacena: This needs to be done in a bulk operation,
+        # instead of several requests.
         for path, ref in refs:
             try:
                 if logger is not None:
@@ -309,3 +321,16 @@ class GitHostingClient:
                 raise GitReferenceDeletionFault(
                     "Error deleting %s from repo %s: HTTP %s" %
                     (ref, path, e.response.status_code))
+
+    def bulkSyncRefs(self, copy_operations, delete_operations):
+        """See `IGitHostingClient`."""
+        # XXX pappacena: This needs to be done in a bulk operation on Turnip
+        # side, instead of several requests.
+        operations_per_path = defaultdict(list)
+        for operation in copy_operations:
+            operations_per_path[operation.source_repo_path].append(operation)
+        for repo_path, operations in operations_per_path:
+            self.copyRefs(repo_path, operations)
+
+        self.deleteRefs([(i.repo_path, i.ref) for i in delete_operations])
+
diff --git a/lib/lp/code/model/gitjob.py b/lib/lp/code/model/gitjob.py
index 1eb87da..77135b3 100644
--- a/lib/lp/code/model/gitjob.py
+++ b/lib/lp/code/model/gitjob.py
@@ -12,6 +12,7 @@ __all__ = [
     'ReclaimGitRepositorySpaceJob',
     ]
 
+from itertools import chain
 
 from lazr.delegates import delegate_to
 from lazr.enum import (
@@ -33,10 +34,15 @@ from zope.interface import (
     provider,
     )
 
+from lp.app.enums import (
+    InformationType,
+    PRIVATE_INFORMATION_TYPES,
+    )
 from lp.app.errors import NotFoundError
 from lp.code.enums import (
     GitActivityType,
     GitPermissionType,
+    GitRepositoryStatus,
     )
 from lp.code.interfaces.githosting import IGitHostingClient
 from lp.code.interfaces.gitjob import (
@@ -45,6 +51,8 @@ from lp.code.interfaces.gitjob import (
     IGitRefScanJobSource,
     IGitRepositoryModifiedMailJob,
     IGitRepositoryModifiedMailJobSource,
+    IGitRepositoryTransitionToInformationTypeJob,
+    IGitRepositoryTransitionToInformationTypeJobSource,
     IReclaimGitRepositorySpaceJob,
     IReclaimGitRepositorySpaceJobSource,
     )
@@ -100,6 +108,13 @@ class GitJobType(DBEnumeratedType):
         modifications.
         """)
 
+    REPOSITORY_TRANSITION_TO_INFO_TYPE = DBItem(3, """
+        Change repository's information type
+
+        This job runs when a user requests to change privacy settings of a
+        repository.
+    """)
+
 
 @implementer(IGitJob)
 class GitJob(StormBase):
@@ -393,3 +408,72 @@ class GitRepositoryModifiedMailJob(GitJobDerived):
     def run(self):
         """See `IGitRepositoryModifiedMailJob`."""
         self.getMailer().sendAll()
+
+
+@implementer(IGitRepositoryTransitionToInformationTypeJob)
+@provider(IGitRepositoryTransitionToInformationTypeJobSource)
+class GitRepositoryTransitionToInformationTypeJob(GitJobDerived):
+    """A Job to change git repository's information type."""
+
+    class_job_type = GitJobType.REPOSITORY_TRANSITION_TO_INFO_TYPE
+
+    config = config.IGitRepositoryTransitionToInformationTypeJobSource
+
+    @classmethod
+    def create(cls, repository, information_type, user, verify_policy=True):
+        """See `IGitRepositoryTransitionToInformationTypeJobSource`."""
+        metadata = {
+            "user": user.id,
+            "information_type": information_type.value,
+            "verify_policy": verify_policy,
+            }
+        git_job = GitJob(repository, cls.class_job_type, metadata)
+        job = cls(git_job)
+        job.celeryRunOnCommit()
+        return job
+
+    @property
+    def user(self):
+        return getUtility(IPersonSet).get(self.metadata["user"])
+
+    @property
+    def verify_policy(self):
+        return self.metadata["verify_policy"]
+
+    @property
+    def information_type(self):
+        return InformationType.items[self.metadata["information_type"]]
+
+    def updateVirtualRefs(self):
+        copy_operations = []
+        delete_operations = []
+        merge_proposals = chain(
+            self.repository.landing_targets,
+            self.repository.landing_candidates)
+        for mp in merge_proposals:
+            copy_operations += mp.getGitRefCopyOperations()
+            delete_operations += mp.getGitRefDeleteOperations()
+        hosting_client = getUtility(IGitHostingClient)
+        hosting_client.bulkSyncRefs(copy_operations, delete_operations)
+
+    def run(self):
+        """See `IGitRepositoryTransitionToInformationTypeJob`."""
+        if (self.repository.status !=
+                GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION):
+            raise AttributeError(
+                "The repository %s is not pending information type change." %
+                self.repository)
+        is_private = self.repository.private
+        will_be_private = self.information_type in PRIVATE_INFORMATION_TYPES
+
+        self.repository._transitionToInformationType(
+            self.information_type, self.user, self.verify_policy)
+
+        # When changing privacy type, we need to make sure to not leak
+        # anything though virtual refs, and create new ones that didn't
+        # exist before.
+        if is_private != will_be_private:
+            # XXX pappacena 2020-10-28: We need to implement retry rules here.
+            self.updateVirtualRefs()
+
+        self.repository.status = GitRepositoryStatus.AVAILABLE
diff --git a/lib/lp/code/model/gitref.py b/lib/lp/code/model/gitref.py
index f7dc142..e2f788d 100644
--- a/lib/lp/code/model/gitref.py
+++ b/lib/lp/code/model/gitref.py
@@ -180,7 +180,7 @@ class GitRefMixin:
 
     def transitionToInformationType(self, information_type, user,
                                     verify_policy=True):
-        return self.repository.transitionToInformationType(
+        return self.repository._transitionToInformationType(
             information_type, user, verify_policy=verify_policy)
 
     @property
diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
index fb5e703..731ca3f 100644
--- a/lib/lp/code/model/gitrepository.py
+++ b/lib/lp/code/model/gitrepository.py
@@ -117,7 +117,10 @@ from lp.code.interfaces.gitcollection import (
     IGitCollection,
     )
 from lp.code.interfaces.githosting import IGitHostingClient
-from lp.code.interfaces.gitjob import IGitRefScanJobSource
+from lp.code.interfaces.gitjob import (
+    IGitRefScanJobSource,
+    IGitRepositoryTransitionToInformationTypeJobSource,
+    )
 from lp.code.interfaces.gitlookup import IGitLookup
 from lp.code.interfaces.gitnamespace import (
     get_git_namespace,
@@ -882,6 +885,24 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
     def transitionToInformationType(self, information_type, user,
                                     verify_policy=True):
         """See `IGitRepository`."""
+        if self.status != GitRepositoryStatus.AVAILABLE:
+            raise CannotChangeInformationType(
+                "Cannot change privacy settings while git repository is "
+                "being changed.")
+        self.status = GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION
+        util = getUtility(
+            IGitRepositoryTransitionToInformationTypeJobSource)
+        return util.create(self, information_type, user, verify_policy)
+
+    def _transitionToInformationType(self, information_type, user,
+                                    verify_policy=True):
+        """Synchronously make the change in this repository's information
+        type.
+
+        External callers should use the async, public version of this
+        method, since it deals with the side effects of changing
+        repository's privacy changes.
+        """
         if self.information_type == information_type:
             return
         if (verify_policy and
@@ -1184,9 +1205,12 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
     def updateLandingTargets(self, paths):
         """See `IGitRepository`."""
         jobs = []
-        for merge_proposal in self.getActiveLandingTargets(paths):
+        merge_proposals = self.getActiveLandingTargets(paths)
+        for merge_proposal in merge_proposals:
             jobs.extend(merge_proposal.scheduleDiffUpdates())
-        return jobs
+        copy_ops, delete_ops = BranchMergeProposal.bulkSyncGitVirtualRefs(
+            merge_proposals)
+        return jobs, copy_ops, delete_ops
 
     def _getRecipes(self, paths=None):
         """Undecorated version of recipes for use by `markRecipesStale`."""
diff --git a/lib/lp/code/model/tests/test_branchmergeproposal.py b/lib/lp/code/model/tests/test_branchmergeproposal.py
index 81b94b3..b601ad8 100644
--- a/lib/lp/code/model/tests/test_branchmergeproposal.py
+++ b/lib/lp/code/model/tests/test_branchmergeproposal.py
@@ -67,6 +67,10 @@ from lp.code.interfaces.branchmergeproposal import (
     IBranchMergeProposalGetter,
     IBranchMergeProposalJobSource,
     )
+from lp.code.interfaces.gitrepository import (
+    GIT_CREATE_MP_VIRTUAL_REF,
+    GIT_MP_VIRTUAL_REF_FORMAT,
+    )
 from lp.code.model.branchmergeproposal import (
     BranchMergeProposal,
     BranchMergeProposalGetter,
@@ -97,6 +101,7 @@ from lp.testing import (
     ExpectedException,
     launchpadlib_for,
     login,
+    login_admin,
     login_person,
     person_logged_in,
     TestCaseWithFactory,
@@ -256,6 +261,196 @@ class TestBranchMergeProposalPrivacy(TestCaseWithFactory):
             self.assertContentEqual([owner, team], subscriptions)
 
 
+class TestGitBranchMergeProposalVirtualRefs(TestCaseWithFactory):
+    """Ensure that BranchMergeProposal creation run the appropriate copy
+    and delete of virtual refs, like ref/merge/<id>/head."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestGitBranchMergeProposalVirtualRefs, self).setUp()
+        self.hosting_fixture = self.useFixture(GitHostingFixture())
+
+    def test_should_have_vrefs_public_repos(self):
+        mp = self.factory.makeBranchMergeProposalForGit()
+        self.assertTrue(mp.should_have_git_virtual_refs)
+
+    def test_should_have_vrefs_private_source(self):
+        login_admin()
+        source_repo = self.factory.makeGitRepository(
+            information_type=InformationType.PRIVATESECURITY)
+        target_repo = self.factory.makeGitRepository(target=source_repo.target)
+        [source] = self.factory.makeGitRefs(source_repo)
+        [target] = self.factory.makeGitRefs(target_repo)
+        mp = self.factory.makeBranchMergeProposalForGit(
+            source_ref=source, target_ref=target)
+        self.assertFalse(mp.should_have_git_virtual_refs)
+
+    def test_should_have_vrefs_private_target(self):
+        login_admin()
+        source_repo = self.factory.makeGitRepository()
+        target_repo = self.factory.makeGitRepository(
+            target=source_repo.target,
+            information_type=InformationType.PRIVATESECURITY)
+        [source] = self.factory.makeGitRefs(source_repo)
+        [target] = self.factory.makeGitRefs(target_repo)
+        mp = self.factory.makeBranchMergeProposalForGit(
+            source_ref=source, target_ref=target)
+        self.assertTrue(mp.should_have_git_virtual_refs)
+
+    def test_copy_git_merge_virtual_ref(self):
+        self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
+        mp = self.factory.makeBranchMergeProposalForGit()
+
+        copy_operations = mp.getGitRefCopyOperations()
+        self.assertEqual(1, len(copy_operations))
+        self.assertThat(copy_operations[0], MatchesStructure(
+            source_repo_path=Equals(
+                mp.source_git_repository.getInternalPath()),
+            source_ref=Equals(mp.source_git_commit_sha1),
+            target_repo_path=Equals(
+                mp.target_git_repository.getInternalPath()),
+            target_ref=Equals("refs/merge/%s/head" % mp.id),
+        ))
+        delete_operations = mp.getGitRefDeleteOperations()
+        self.assertEqual([], delete_operations)
+
+        self.assertEqual(1, self.hosting_fixture.bulkSyncRefs.call_count)
+        args, kwargs = self.hosting_fixture.bulkSyncRefs.calls[0]
+        copy_ops, delete_ops = args
+        self.assertEqual({}, kwargs)
+        self.assertEqual([], delete_ops)
+        self.assertEqual(1, len(copy_ops))
+        self.assertThat(copy_ops[0], MatchesStructure(
+            source_repo_path=Equals(
+                mp.source_git_repository.getInternalPath()),
+            source_ref=Equals(mp.source_git_commit_sha1),
+            target_repo_path=Equals(
+                mp.target_git_repository.getInternalPath()),
+            target_ref=Equals("refs/merge/%s/head" % mp.id),
+        ))
+
+    def test_getGitRefCopyOperations_private_source(self):
+        self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
+        login_admin()
+        source_repo = self.factory.makeGitRepository(
+            information_type=InformationType.PRIVATESECURITY)
+        target_repo = self.factory.makeGitRepository(target=source_repo.target)
+        [source] = self.factory.makeGitRefs(source_repo)
+        [target] = self.factory.makeGitRefs(target_repo)
+        mp = self.factory.makeBranchMergeProposalForGit(
+            source_ref=source, target_ref=target)
+        self.assertEqual([], mp.getGitRefCopyOperations())
+
+        delete_operations = mp.getGitRefDeleteOperations()
+        self.assertEqual(1, len(delete_operations))
+        self.assertThat(delete_operations[0], MatchesStructure(
+            repo_path=Equals(mp.target_git_repository.getInternalPath()),
+            ref=Equals("refs/merge/%s/head" % mp.id),
+        ))
+
+    def test_getGitRefCopyOperations_private_source_same_repo(self):
+        self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
+        login_admin()
+        repo = self.factory.makeGitRepository(
+            information_type=InformationType.PRIVATESECURITY)
+        [source, target] = self.factory.makeGitRefs(
+            repo, ['refs/heads/bugfix', 'refs/heads/master'])
+        mp = self.factory.makeBranchMergeProposalForGit(
+            source_ref=source, target_ref=target)
+        operations = mp.getGitRefCopyOperations()
+        self.assertEqual(1, len(operations))
+        self.assertThat(operations[0], MatchesStructure(
+            source_repo_path=Equals(
+                mp.source_git_repository.getInternalPath()),
+            source_ref=Equals(mp.source_git_commit_sha1),
+            target_repo_path=Equals(
+                mp.target_git_repository.getInternalPath()),
+            target_ref=Equals(GIT_MP_VIRTUAL_REF_FORMAT.format(mp=mp))
+        ))
+
+        delete_operations = mp.getGitRefDeleteOperations()
+        self.assertEqual([], delete_operations)
+
+    def test_getGitRefCopyOperations_private_source_same_owner(self):
+        self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
+        login_admin()
+        source_repo = self.factory.makeGitRepository(
+            information_type=InformationType.PRIVATESECURITY)
+        target_repo = self.factory.makeGitRepository(
+            target=source_repo.target, owner=source_repo.owner)
+        [source] = self.factory.makeGitRefs(source_repo)
+        [target] = self.factory.makeGitRefs(target_repo)
+        mp = self.factory.makeBranchMergeProposalForGit(
+            source_ref=source, target_ref=target)
+        operations = mp.getGitRefCopyOperations()
+        self.assertEqual(1, len(operations))
+        self.assertThat(operations[0], MatchesStructure(
+            source_repo_path=Equals(
+                mp.source_git_repository.getInternalPath()),
+            source_ref=Equals(mp.source_git_commit_sha1),
+            target_repo_path=Equals(
+                mp.target_git_repository.getInternalPath()),
+            target_ref=Equals(GIT_MP_VIRTUAL_REF_FORMAT.format(mp=mp))
+        ))
+
+        delete_operations = mp.getGitRefDeleteOperations()
+        self.assertEqual([], delete_operations)
+
+    def test_syncGitVirtualRefs(self):
+        self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
+        login_admin()
+        login_admin()
+        source_repo = self.factory.makeGitRepository()
+        target_repo = self.factory.makeGitRepository(target=source_repo.target)
+        [source] = self.factory.makeGitRefs(source_repo)
+        [target] = self.factory.makeGitRefs(target_repo)
+        mp = self.factory.makeBranchMergeProposalForGit(
+            source_ref=source, target_ref=target)
+
+        # mp.syncGitVirtualRefs should have been triggered by event.
+        # Check lp.code.subscribers.branchmergeproposal.merge_proposal_created.
+        self.assertEqual(1, self.hosting_fixture.bulkSyncRefs.call_count)
+        args, kwargs = self.hosting_fixture.bulkSyncRefs.calls[0]
+        copy_ops, delete_ops = args
+        self.assertEquals({}, kwargs)
+        self.assertEqual([], delete_ops)
+        self.assertEqual(1, len(copy_ops))
+        self.assertThat(copy_ops[0], MatchesStructure(
+            source_repo_path=Equals(
+                mp.source_git_repository.getInternalPath()),
+            source_ref=Equals(mp.source_git_commit_sha1),
+            target_repo_path=Equals(
+                mp.target_git_repository.getInternalPath()),
+            target_ref=Equals("refs/merge/%s/head" % mp.id),
+        ))
+
+        self.assertEqual(0, self.hosting_fixture.deleteRefs.call_count)
+
+    def test_syncGitVirtualRefs_private_source_deletes_ref(self):
+        self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
+        login_admin()
+        source_repo = self.factory.makeGitRepository(
+            information_type=InformationType.PRIVATESECURITY)
+        target_repo = self.factory.makeGitRepository(target=source_repo.target)
+        [source] = self.factory.makeGitRefs(source_repo)
+        [target] = self.factory.makeGitRefs(target_repo)
+        mp = self.factory.makeBranchMergeProposalForGit(
+            source_ref=source, target_ref=target)
+
+        # mp.syncGitVirtualRefs should have been triggered by event.
+        # Check lp.code.subscribers.branchmergeproposal.merge_proposal_created.
+        self.assertEqual(1, self.hosting_fixture.bulkSyncRefs.call_count)
+        args, kwargs = self.hosting_fixture.bulkSyncRefs.calls[0]
+        copy_ops, delete_ops = args
+        self.assertEqual({}, kwargs)
+        self.assertEqual([], copy_ops)
+        self.assertEqual(1, len(delete_ops))
+        self.assertThat(delete_ops[0], MatchesStructure(
+            repo_path=Equals(target_repo.getInternalPath()),
+            ref=Equals("refs/merge/%s/head" % mp.id)))
+
+
 class TestBranchMergeProposalTransitions(TestCaseWithFactory):
     """Test the state transitions of branch merge proposals."""
 
@@ -1185,6 +1380,10 @@ class TestMergeProposalWebhooks(WithVCSScenarios, TestCaseWithFactory):
     def test_delete_triggers_webhooks(self):
         # When an existing merge proposal is deleted, any relevant webhooks
         # are triggered.
+        self.useFixture(FeatureFixture({
+            GIT_CREATE_MP_VIRTUAL_REF: "on",
+            BRANCH_MERGE_PROPOSAL_WEBHOOKS_FEATURE_FLAG: "on"}))
+        hosting_fixture = self.useFixture(GitHostingFixture())
         logger = self.useFixture(FakeLogger())
         source = self.makeBranch()
         target = self.makeBranch(same_target_as=source)
@@ -1203,10 +1402,25 @@ class TestMergeProposalWebhooks(WithVCSScenarios, TestCaseWithFactory):
         expected_redacted_payload = dict(
             expected_payload,
             old=MatchesDict(self.getExpectedPayload(proposal, redact=True)))
+        hosting_fixture = self.useFixture(GitHostingFixture())
         proposal.deleteProposal()
         delivery = hook.deliveries.one()
+        self.assertIsNotNone(delivery)
         self.assertCorrectDelivery(expected_payload, hook, delivery)
         self.assertCorrectLogging(expected_redacted_payload, hook, logger)
+        if not self.git:
+            self.assertEqual(0, hosting_fixture.bulkSyncRefs.call_count)
+        else:
+            self.assertEqual(1, hosting_fixture.bulkSyncRefs.call_count)
+            args, kwargs = hosting_fixture.bulkSyncRefs.calls[0]
+            self.assertEqual({}, kwargs)
+            copy_ops, delete_ops = args
+            self.assertEqual(0, len(copy_ops))
+            self.assertEqual(1, len(delete_ops))
+            self.assertThat(delete_ops[0], MatchesStructure(
+                repo_path=Equals(target.repository.getInternalPath()),
+                ref=Equals("refs/merge/%s/head" % proposal.id)
+            ))
 
 
 class TestGetAddress(TestCaseWithFactory):
@@ -1507,6 +1721,26 @@ class TestBranchMergeProposalDeletion(TestCaseWithFactory):
         self.assertRaises(
             SQLObjectNotFound, BranchMergeProposalJob.get, job_id)
 
+    def test_deleteProposal_for_git_removes_virtual_ref(self):
+        self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
+        self.useFixture(GitHostingFixture())
+        proposal = self.factory.makeBranchMergeProposalForGit()
+
+        # Clean up fixture calls.
+        hosting_fixture = self.useFixture(GitHostingFixture())
+        proposal.deleteProposal()
+
+        self.assertEqual(1, hosting_fixture.bulkSyncRefs.call_count)
+        args, kwargs = hosting_fixture.bulkSyncRefs.calls[0]
+        self.assertEqual({}, kwargs)
+        copy_ops, delete_ops = args
+        self.assertEqual([], copy_ops)
+        self.assertEqual(1, len(delete_ops))
+        self.assertThat(delete_ops[0], MatchesStructure(
+            repo_path=Equals(proposal.target_git_repository.getInternalPath()),
+            ref=Equals('refs/merge/%s/head' % proposal.id)
+        ))
+
 
 class TestBranchMergeProposalBugs(WithVCSScenarios, TestCaseWithFactory):
 
diff --git a/lib/lp/code/model/tests/test_gitcollection.py b/lib/lp/code/model/tests/test_gitcollection.py
index 447fcb4..372a633 100644
--- a/lib/lp/code/model/tests/test_gitcollection.py
+++ b/lib/lp/code/model/tests/test_gitcollection.py
@@ -599,7 +599,7 @@ class TestBranchMergeProposals(TestCaseWithFactory):
         registrant = self.factory.makePerson()
         mp1 = self.factory.makeBranchMergeProposalForGit(registrant=registrant)
         naked_repository = removeSecurityProxy(mp1.target_git_repository)
-        naked_repository.transitionToInformationType(
+        naked_repository._transitionToInformationType(
             InformationType.USERDATA, registrant, verify_policy=False)
         collection = self.all_repositories.visibleByUser(None)
         proposals = collection.getMergeProposals()
diff --git a/lib/lp/code/model/tests/test_githosting.py b/lib/lp/code/model/tests/test_githosting.py
index 23d6063..fad0df3 100644
--- a/lib/lp/code/model/tests/test_githosting.py
+++ b/lib/lp/code/model/tests/test_githosting.py
@@ -42,7 +42,6 @@ from lp.code.errors import (
     GitRepositoryDeletionFault,
     GitRepositoryScanFault,
     GitTargetError,
-    NoSuchGitReference,
     )
 from lp.code.interfaces.githosting import IGitHostingClient
 from lp.code.model.githosting import RefCopyOperation
@@ -414,8 +413,8 @@ class TestGitHostingClient(TestCase):
 
     def getCopyRefOperations(self):
         return [
-            RefCopyOperation("1a2b3c4", "999", "refs/merge/123"),
-            RefCopyOperation("9a8b7c6", "666", "refs/merge/989"),
+            RefCopyOperation("1", "1a2b3c4", "999", "refs/merge/123"),
+            RefCopyOperation("1", "9a8b7c6", "666", "refs/merge/989"),
         ]
 
     def test_copyRefs(self):
diff --git a/lib/lp/code/model/tests/test_gitjob.py b/lib/lp/code/model/tests/test_gitjob.py
index 3f606c0..69cb653 100644
--- a/lib/lp/code/model/tests/test_gitjob.py
+++ b/lib/lp/code/model/tests/test_gitjob.py
@@ -25,13 +25,16 @@ from testtools.matchers import (
     MatchesStructure,
     )
 import transaction
+from zope.component import getUtility
 from zope.interface import providedBy
 from zope.security.proxy import removeSecurityProxy
 
+from lp.app.enums import InformationType
 from lp.code.adapters.gitrepository import GitRepositoryDelta
 from lp.code.enums import (
     GitGranteeType,
     GitObjectType,
+    GitRepositoryStatus,
     )
 from lp.code.interfaces.branchmergeproposal import (
     BRANCH_MERGE_PROPOSAL_WEBHOOKS_FEATURE_FLAG,
@@ -39,6 +42,7 @@ from lp.code.interfaces.branchmergeproposal import (
 from lp.code.interfaces.gitjob import (
     IGitJob,
     IGitRefScanJob,
+    IGitRepositoryTransitionToInformationTypeJobSource,
     IReclaimGitRepositorySpaceJob,
     )
 from lp.code.model.gitjob import (
@@ -47,9 +51,11 @@ from lp.code.model.gitjob import (
     GitJobDerived,
     GitJobType,
     GitRefScanJob,
+    GitRepositoryTransitionToInformationTypeJob,
     ReclaimGitRepositorySpaceJob,
     )
 from lp.code.tests.helpers import GitHostingFixture
+from lp.registry.errors import CannotChangeInformationType
 from lp.services.config import config
 from lp.services.database.constants import UTC_NOW
 from lp.services.features.testing import FeatureFixture
@@ -362,6 +368,65 @@ class TestReclaimGitRepositorySpaceJob(TestCaseWithFactory):
         self.assertEqual([(path,)], hosting_fixture.delete.extract_args())
 
 
+class TestGitRepositoryTransitionInformationType(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def test_block_multiple_requests_to_change_info_type(self):
+        repo = self.factory.makeGitRepository()
+        repo.transitionToInformationType(
+            InformationType.PRIVATESECURITY, repo.owner)
+        self.assertEqual(
+            GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION,
+            repo.status)
+        expected_msg = (
+            "Cannot change privacy settings while git repository is "
+            "being changed.")
+        self.assertRaisesRegex(
+            CannotChangeInformationType, expected_msg,
+            repo.transitionToInformationType,
+            InformationType.PROPRIETARY, repo.owner)
+
+    def test_avoid_transitioning_while_creating(self):
+        repo = self.factory.makeGitRepository()
+        removeSecurityProxy(repo).status = GitRepositoryStatus.CREATING
+        expected_msg = (
+            "Cannot change privacy settings while git repository is "
+            "being changed.")
+        self.assertRaisesRegex(
+            CannotChangeInformationType, expected_msg,
+            repo.transitionToInformationType,
+            InformationType.PROPRIETARY, repo.owner)
+
+    def test_run_changes_info_type(self):
+        repo = self.factory.makeGitRepository(
+            information_type=InformationType.PUBLIC)
+        # Change to a private info type and with verify_policy, so we hit as
+        # many database tables as possible.
+        repo.transitionToInformationType(
+            InformationType.PRIVATESECURITY, repo.owner, verify_policy=True)
+        self.assertEqual(
+            GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION,
+            repo.status)
+
+        job_util = getUtility(
+            IGitRepositoryTransitionToInformationTypeJobSource)
+        jobs = list(job_util.iterReady())
+        self.assertEqual(1, len(jobs))
+        with dbuser(GitRepositoryTransitionToInformationTypeJob.config.dbuser):
+            JobRunner(jobs).runAll()
+
+        self.assertEqual(GitRepositoryStatus.AVAILABLE, repo.status)
+        self.assertEqual(
+            InformationType.PRIVATESECURITY, repo.information_type)
+
+        # After the job finished, another change is possible.
+        repo.transitionToInformationType(InformationType.PUBLIC, repo.owner)
+        self.assertEqual(
+            GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION,
+            repo.status)
+
+
 class TestDescribeRepositoryDelta(TestCaseWithFactory):
     """Tests for `describe_repository_delta`."""
 
diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py
index b83f9da..2296352 100644
--- a/lib/lp/code/model/tests/test_gitrepository.py
+++ b/lib/lp/code/model/tests/test_gitrepository.py
@@ -97,6 +97,7 @@ from lp.code.interfaces.gitnamespace import (
     IGitNamespaceSet,
     )
 from lp.code.interfaces.gitrepository import (
+    GIT_CREATE_MP_VIRTUAL_REF,
     IGitRepository,
     IGitRepositorySet,
     IGitRepositoryView,
@@ -783,6 +784,7 @@ class TestGitRepositoryDeletion(TestCaseWithFactory):
         # Make sure that the tests all flush the database changes.
         self.addCleanup(Store.of(self.repository).flush)
         login_person(self.user)
+        self.hosting_fixture = self.useFixture(GitHostingFixture())
 
     def test_deletable(self):
         # A newly created repository can be deleted without any problems.
@@ -809,7 +811,7 @@ class TestGitRepositoryDeletion(TestCaseWithFactory):
 
     def test_private_subscription_does_not_disable_deletion(self):
         # A private repository that has a subscription can be deleted.
-        self.repository.transitionToInformationType(
+        removeSecurityProxy(self.repository)._transitionToInformationType(
             InformationType.USERDATA, self.repository.owner,
             verify_policy=False)
         self.repository.subscribe(
@@ -824,7 +826,6 @@ class TestGitRepositoryDeletion(TestCaseWithFactory):
 
     def test_code_import_does_not_disable_deletion(self):
         # A repository that has an attached code import can be deleted.
-        self.useFixture(GitHostingFixture())
         code_import = self.factory.makeCodeImport(
             target_rcs_type=TargetRevisionControlSystems.GIT)
         repository = code_import.git_repository
@@ -984,6 +985,7 @@ class TestGitRepositoryDeletionConsequences(TestCaseWithFactory):
         # unsubscribe the repository owner here.
         self.repository.unsubscribe(
             self.repository.owner, self.repository.owner)
+        self.hosting_fixture = self.useFixture(GitHostingFixture())
 
     def test_plain_repository(self):
         # A fresh repository has no deletion requirements.
@@ -1115,7 +1117,6 @@ class TestGitRepositoryDeletionConsequences(TestCaseWithFactory):
 
     def test_code_import_requirements(self):
         # Code imports are not included explicitly in deletion requirements.
-        self.useFixture(GitHostingFixture())
         code_import = self.factory.makeCodeImport(
             target_rcs_type=TargetRevisionControlSystems.GIT)
         # Remove the implicit repository subscription first.
@@ -1126,7 +1127,6 @@ class TestGitRepositoryDeletionConsequences(TestCaseWithFactory):
 
     def test_code_import_deletion(self):
         # break_references allows deleting a code import repository.
-        self.useFixture(GitHostingFixture())
         code_import = self.factory.makeCodeImport(
             target_rcs_type=TargetRevisionControlSystems.GIT)
         code_import_id = code_import.id
@@ -1182,7 +1182,6 @@ class TestGitRepositoryDeletionConsequences(TestCaseWithFactory):
 
     def test_DeleteCodeImport(self):
         # DeleteCodeImport.__call__ must delete the CodeImport.
-        self.useFixture(GitHostingFixture())
         code_import = self.factory.makeCodeImport(
             target_rcs_type=TargetRevisionControlSystems.GIT)
         code_import_id = code_import.id
@@ -1521,6 +1520,10 @@ class TestGitRepositoryRefs(TestCaseWithFactory):
 
     layer = DatabaseFunctionalLayer
 
+    def setUp(self):
+        super(TestGitRepositoryRefs, self).setUp()
+        self.hosting_fixture = self.useFixture(GitHostingFixture())
+
     def test__convertRefInfo(self):
         # _convertRefInfo converts a valid info dictionary.
         sha1 = six.ensure_text(hashlib.sha1("").hexdigest())
@@ -1791,18 +1794,17 @@ class TestGitRepositoryRefs(TestCaseWithFactory):
         # planRefChanges excludes some ref prefixes by default, and can be
         # configured otherwise.
         repository = self.factory.makeGitRepository()
-        hosting_fixture = self.useFixture(GitHostingFixture())
         repository.planRefChanges("dummy")
         self.assertEqual(
             [{"exclude_prefixes": ["refs/changes/"]}],
-            hosting_fixture.getRefs.extract_kwargs())
-        hosting_fixture.getRefs.calls = []
+            self.hosting_fixture.getRefs.extract_kwargs())
+        self.hosting_fixture.getRefs.calls = []
         self.pushConfig(
             "codehosting", git_exclude_ref_prefixes="refs/changes/ refs/pull/")
         repository.planRefChanges("dummy")
         self.assertEqual(
             [{"exclude_prefixes": ["refs/changes/", "refs/pull/"]}],
-            hosting_fixture.getRefs.extract_kwargs())
+            self.hosting_fixture.getRefs.extract_kwargs())
 
     def test_fetchRefCommits(self):
         # fetchRefCommits fetches detailed tip commit metadata for the
@@ -1875,9 +1877,8 @@ class TestGitRepositoryRefs(TestCaseWithFactory):
     def test_fetchRefCommits_empty(self):
         # If given an empty refs dictionary, fetchRefCommits returns early
         # without contacting the hosting service.
-        hosting_fixture = self.useFixture(GitHostingFixture())
         GitRepository.fetchRefCommits("dummy", {})
-        self.assertEqual([], hosting_fixture.getCommits.calls)
+        self.assertEqual([], self.hosting_fixture.getCommits.calls)
 
     def test_synchroniseRefs(self):
         # synchroniseRefs copes with synchronising a repository where some
@@ -1920,7 +1921,6 @@ class TestGitRepositoryRefs(TestCaseWithFactory):
         self.assertThat(repository.refs, MatchesSetwise(*matchers))
 
     def test_set_default_branch(self):
-        hosting_fixture = self.useFixture(GitHostingFixture())
         repository = self.factory.makeGitRepository()
         self.factory.makeGitRefs(
             repository=repository,
@@ -1931,22 +1931,20 @@ class TestGitRepositoryRefs(TestCaseWithFactory):
         self.assertEqual(
             [((repository.getInternalPath(),),
              {"default_branch": "refs/heads/new"})],
-            hosting_fixture.setProperties.calls)
+            self.hosting_fixture.setProperties.calls)
         self.assertEqual("refs/heads/new", repository.default_branch)
 
     def test_set_default_branch_unchanged(self):
-        hosting_fixture = self.useFixture(GitHostingFixture())
         repository = self.factory.makeGitRepository()
         self.factory.makeGitRefs(
             repository=repository, paths=["refs/heads/master"])
         removeSecurityProxy(repository)._default_branch = "refs/heads/master"
         with person_logged_in(repository.owner):
             repository.default_branch = "master"
-        self.assertEqual([], hosting_fixture.setProperties.calls)
+        self.assertEqual([], self.hosting_fixture.setProperties.calls)
         self.assertEqual("refs/heads/master", repository.default_branch)
 
     def test_set_default_branch_imported(self):
-        hosting_fixture = self.useFixture(GitHostingFixture())
         repository = self.factory.makeGitRepository(
             repository_type=GitRepositoryType.IMPORTED)
         self.factory.makeGitRefs(
@@ -1959,7 +1957,7 @@ class TestGitRepositoryRefs(TestCaseWithFactory):
                 "Cannot modify non-hosted Git repository %s." %
                     repository.display_name,
                 setattr, repository, "default_branch", "new")
-        self.assertEqual([], hosting_fixture.setProperties.calls)
+        self.assertEqual([], self.hosting_fixture.setProperties.calls)
         self.assertEqual("refs/heads/master", repository.default_branch)
 
     def test_exception_unset_default_branch(self):
@@ -2055,7 +2053,8 @@ class TestGitRepositoryModerate(TestCaseWithFactory):
             repository.transitionToInformationType(
                 InformationType.PRIVATESECURITY, project.owner)
             self.assertEqual(
-                InformationType.PRIVATESECURITY, repository.information_type)
+                GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION,
+                repository.status)
 
     def test_attribute_smoketest(self):
         # Users with launchpad.Moderate can set attributes.
@@ -2580,18 +2579,66 @@ class TestGitRepositoryUpdateLandingTargets(TestCaseWithFactory):
 
     layer = DatabaseFunctionalLayer
 
-    def test_schedules_diff_updates(self):
+    def setUp(self):
+        super(TestGitRepositoryUpdateLandingTargets, self).setUp()
+        self.hosting_fixture = self.useFixture(GitHostingFixture())
+
+    def assertSchedulesDiffUpdate(self, with_mp_virtual_ref):
         """Create jobs for all merge proposals."""
         bmp1 = self.factory.makeBranchMergeProposalForGit()
         bmp2 = self.factory.makeBranchMergeProposalForGit(
             source_ref=bmp1.source_git_ref)
-        jobs = bmp1.source_git_repository.updateLandingTargets(
-            [bmp1.source_git_path])
+
+        # Only enable this virtual ref here, since
+        # self.factory.makeBranchMergeProposalForGit also tries to create
+        # the virtual refs.
+        if with_mp_virtual_ref:
+            self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
+        else:
+            self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: ""}))
+        jobs, ref_copies, ref_deletes = (
+            bmp1.source_git_repository.updateLandingTargets(
+                [bmp1.source_git_path]))
         self.assertEqual(2, len(jobs))
         bmps_to_update = [
             removeSecurityProxy(job).branch_merge_proposal for job in jobs]
         self.assertContentEqual([bmp1, bmp2], bmps_to_update)
 
+        if not with_mp_virtual_ref:
+            self.assertEqual(
+                0, self.hosting_fixture.bulkSyncRefs.call_count)
+        else:
+            self.assertEqual(
+                1, self.hosting_fixture.bulkSyncRefs.call_count)
+            args, kwargs = self.hosting_fixture.bulkSyncRefs.calls[0]
+            self.assertEqual({}, kwargs)
+            self.assertEqual(2, len(args))
+            copy_ops, delete_ops = args
+            self.assertEqual(2, len(copy_ops))
+            self.assertEqual(0, len(delete_ops))
+            self.assertThat(copy_ops[0], MatchesStructure(
+                source_repo_path=Equals(
+                    bmp1.source_git_repository.getInternalPath()),
+                source_ref=Equals(bmp1.source_git_commit_sha1),
+                target_repo_path=Equals(
+                    bmp1.target_git_repository.getInternalPath()),
+                target_ref=Equals("refs/merge/%s/head" % bmp1.id),
+            ))
+            self.assertThat(copy_ops[1], MatchesStructure(
+                source_repo_path=Equals(
+                    bmp2.source_git_repository.getInternalPath()),
+                source_ref=Equals(bmp2.source_git_commit_sha1),
+                target_repo_path=Equals(
+                    bmp2.target_git_repository.getInternalPath()),
+                target_ref=Equals("refs/merge/%s/head" % bmp2.id),
+            ))
+
+    def test_schedules_diff_updates_with_mp_virtual_ref(self):
+        self.assertSchedulesDiffUpdate(with_mp_virtual_ref=True)
+
+    def test_schedules_diff_updates_without_mp_virtual_ref(self):
+        self.assertSchedulesDiffUpdate(with_mp_virtual_ref=False)
+
     def test_ignores_final(self):
         """Diffs for proposals in final states aren't updated."""
         [source_ref] = self.factory.makeGitRefs()
@@ -2603,8 +2650,17 @@ class TestGitRepositoryUpdateLandingTargets(TestCaseWithFactory):
         for bmp in source_ref.landing_targets:
             if bmp.queue_status not in FINAL_STATES:
                 removeSecurityProxy(bmp).deleteProposal()
-        jobs = source_ref.repository.updateLandingTargets([source_ref.path])
+
+        # Enable the feature here, since factory.makeBranchMergeProposalForGit
+        # would also trigger the copy refs call.
+        self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
+        jobs, ref_copies, ref_deletes = (
+            source_ref.repository.updateLandingTargets(
+                [source_ref.path]))
         self.assertEqual(0, len(jobs))
+        self.assertEqual(0, len(ref_copies))
+        self.assertEqual(0, len(ref_deletes))
+        self.assertEqual(0, self.hosting_fixture.copyRefs.call_count)
 
 
 class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory):
@@ -3454,7 +3510,7 @@ class TestGitRepositorySet(TestCaseWithFactory):
             ]
         for repository, modified_date in zip(repositories, modified_dates):
             removeSecurityProxy(repository).date_last_modified = modified_date
-        removeSecurityProxy(repositories[0]).transitionToInformationType(
+        removeSecurityProxy(repositories[0])._transitionToInformationType(
             InformationType.PRIVATESECURITY, repositories[0].registrant)
         self.assertEqual(
             [repositories[3], repositories[4], repositories[1],
@@ -3967,7 +4023,8 @@ class TestGitRepositoryWebservice(TestCaseWithFactory):
         self.assertEqual(209, response.status)
         with person_logged_in(ANONYMOUS):
             self.assertEqual(
-                InformationType.PUBLICSECURITY, repository_db.information_type)
+                GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION,
+                repository_db.status)
 
     def test_set_information_type_other_person(self):
         # An unrelated user cannot change the information type.
diff --git a/lib/lp/code/subscribers/branchmergeproposal.py b/lib/lp/code/subscribers/branchmergeproposal.py
index a214c8f..a34ca2a 100644
--- a/lib/lp/code/subscribers/branchmergeproposal.py
+++ b/lib/lp/code/subscribers/branchmergeproposal.py
@@ -1,4 +1,4 @@
-# Copyright 2010-2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2010-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Event subscribers for branch merge proposals."""
@@ -63,9 +63,13 @@ def _trigger_webhook(merge_proposal, payload):
 def merge_proposal_created(merge_proposal, event):
     """A new merge proposal has been created.
 
-    Create a job to update the diff for the merge proposal; trigger webhooks.
+    Create a job to update the diff for the merge proposal; trigger webhooks
+    and copy virtual refs as needed.
     """
     getUtility(IUpdatePreviewDiffJobSource).create(merge_proposal)
+
+    merge_proposal.syncGitVirtualRefs()
+
     if getFeatureFlag(BRANCH_MERGE_PROPOSAL_WEBHOOKS_FEATURE_FLAG):
         payload = {
             "action": "created",
@@ -149,3 +153,5 @@ def merge_proposal_deleted(merge_proposal, event):
             "old": _compose_merge_proposal_webhook_payload(merge_proposal),
             }
         _trigger_webhook(merge_proposal, payload)
+
+    merge_proposal.syncGitVirtualRefs(force_delete=True)
diff --git a/lib/lp/code/tests/helpers.py b/lib/lp/code/tests/helpers.py
index 4ef843d..3704b06 100644
--- a/lib/lp/code/tests/helpers.py
+++ b/lib/lp/code/tests/helpers.py
@@ -369,6 +369,9 @@ class GitHostingFixture(fixtures.Fixture):
         self.getBlob = FakeMethod(result=blob)
         self.delete = FakeMethod()
         self.disable_memcache = disable_memcache
+        self.copyRefs = FakeMethod()
+        self.deleteRefs = FakeMethod()
+        self.bulkSyncRefs = FakeMethod()
 
     def _setUp(self):
         self.useFixture(ZopeUtilityFixture(self, IGitHostingClient))
diff --git a/lib/lp/code/xmlrpc/tests/test_git.py b/lib/lp/code/xmlrpc/tests/test_git.py
index dcbcee6..e884cd2 100644
--- a/lib/lp/code/xmlrpc/tests/test_git.py
+++ b/lib/lp/code/xmlrpc/tests/test_git.py
@@ -899,7 +899,7 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
             for _ in range(2)]
         private_repository = code_imports[0].git_repository
         removeSecurityProxy(
-            private_repository).transitionToInformationType(
+            private_repository)._transitionToInformationType(
             InformationType.PRIVATESECURITY, private_repository.owner)
         with celebrity_logged_in("vcs_imports"):
             jobs = [
@@ -1077,7 +1077,7 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
             for _ in range(2)]
         private_repository = code_imports[0].git_repository
         removeSecurityProxy(
-            private_repository).transitionToInformationType(
+            private_repository)._transitionToInformationType(
             InformationType.PRIVATESECURITY, private_repository.owner)
         with celebrity_logged_in("vcs_imports"):
             jobs = [
@@ -1687,7 +1687,7 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
                 target_rcs_type=TargetRevisionControlSystems.GIT)
             for _ in range(2)]
         private_repository = code_imports[0].git_repository
-        removeSecurityProxy(private_repository).transitionToInformationType(
+        removeSecurityProxy(private_repository)._transitionToInformationType(
             InformationType.PRIVATESECURITY, private_repository.owner)
         with celebrity_logged_in("vcs_imports"):
             jobs = [
@@ -2012,7 +2012,7 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
                 target_rcs_type=TargetRevisionControlSystems.GIT)
             for _ in range(2)]
         private_repository = code_imports[0].git_repository
-        removeSecurityProxy(private_repository).transitionToInformationType(
+        removeSecurityProxy(private_repository)._transitionToInformationType(
             InformationType.PRIVATESECURITY, private_repository.owner)
         with celebrity_logged_in("vcs_imports"):
             jobs = [
@@ -2268,7 +2268,7 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
                 target_rcs_type=TargetRevisionControlSystems.GIT)
             for _ in range(2)]
         private_repository = code_imports[0].git_repository
-        removeSecurityProxy(private_repository).transitionToInformationType(
+        removeSecurityProxy(private_repository)._transitionToInformationType(
             InformationType.PRIVATESECURITY, private_repository.owner)
         with celebrity_logged_in("vcs_imports"):
             jobs = [
diff --git a/lib/lp/scripts/garbo.py b/lib/lp/scripts/garbo.py
index 6e0feac..ea248a4 100644
--- a/lib/lp/scripts/garbo.py
+++ b/lib/lp/scripts/garbo.py
@@ -38,6 +38,7 @@ from storm.expr import (
     Cast,
     In,
     Join,
+    LeftJoin,
     Max,
     Min,
     Or,
@@ -68,6 +69,10 @@ from lp.code.model.diff import (
     Diff,
     PreviewDiff,
     )
+from lp.code.model.gitjob import (
+    GitJob,
+    GitJobType,
+    )
 from lp.code.model.gitrepository import GitRepository
 from lp.code.model.revision import (
     RevisionAuthor,
@@ -1554,6 +1559,54 @@ class GitRepositoryPruner(TunableLoop):
         transaction.commit()
 
 
+class GitRepositoryBrokenInfoTypeTransition(TunableLoop):
+    """Put back to "AVAILABLE" repositories that are pending information
+    type changes, but we don't have any git job that will actually do that
+    in the upcoming future.
+    """
+
+    maximum_chunk_size = 500
+
+    def __init__(self, log, abort_time=None):
+        super(GitRepositoryBrokenInfoTypeTransition, self).__init__(
+            log, abort_time)
+        self.store = IMasterStore(GitRepository)
+
+    def findRepositories(self):
+        pending_change = (
+            GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION)
+        job_type = GitJobType.REPOSITORY_TRANSITION_TO_INFO_TYPE
+        job_pending_statuses = (JobStatus.WAITING, JobStatus.RUNNING)
+        # Get git repositories left-joining with
+        # REPOSITORY_TRANSITION_TO_INFO_TYPE GitJobs waiting to be run (or
+        # already running).
+        join = [
+            GitRepository,
+            LeftJoin(
+                GitJob,
+                And(GitJob.repository_id == GitRepository.id,
+                    GitJob.job_type == job_type)),
+            LeftJoin(
+                Job,
+                And(GitJob.job_id == Job.id,
+                    Job._status.is_in(job_pending_statuses)))]
+        # We get only the repositories pending change without associated job.
+        result_set = self.store.using(*join).find(
+            GitRepository,
+            GitRepository.status == pending_change,
+            Job._status == None)
+        return result_set.order_by(GitRepository.date_created)
+
+    def isDone(self):
+        return self.findRepositories().is_empty()
+
+    def __call__(self, chunk_size):
+        repositories = self.findRepositories()[:chunk_size]
+        for repository in repositories:
+            repository.status = GitRepositoryStatus.AVAILABLE
+        transaction.commit()
+
+
 class BaseDatabaseGarbageCollector(LaunchpadCronScript):
     """Abstract base class to run a collection of TunableLoops."""
     script_name = None  # Script name for locking and database user. Override.
@@ -1806,6 +1859,7 @@ class HourlyDatabaseGarbageCollector(BaseDatabaseGarbageCollector):
         BugHeatUpdater,
         DuplicateSessionPruner,
         GitRepositoryPruner,
+        GitRepositoryBrokenInfoTypeTransition,
         RevisionCachePruner,
         UnusedSessionPruner,
         ]
diff --git a/lib/lp/scripts/tests/test_garbo.py b/lib/lp/scripts/tests/test_garbo.py
index 2fbc80d..ae3b0f1 100644
--- a/lib/lp/scripts/tests/test_garbo.py
+++ b/lib/lp/scripts/tests/test_garbo.py
@@ -1126,6 +1126,66 @@ class TestGarbo(FakeAdapterMixin, TestCaseWithFactory):
             {old_available, recent_available, recent_creating},
             set(remaining_repos))
 
+    def test_GitRepositoryBrokenInfoTypeTransition_changes_status(self):
+        self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
+        # Shortcuts.
+        available = GitRepositoryStatus.AVAILABLE
+        pending_transition = (
+            GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION)
+
+        switch_dbuser('testadmin')
+        store = IMasterStore(GitRepository)
+        created_repos = [self.factory.makeGitRepository() for i in range(5)]
+
+        pending_without_job_repos = []
+        for i in range(2):
+            repo = self.factory.makeGitRepository()
+            removeSecurityProxy(repo)._status = pending_transition
+
+        pending_with_failed_jobs = []
+        for i in range(3):
+            repo = self.factory.makeGitRepository()
+            # For some repos, create the job but force them to fail
+            job = repo.transitionToInformationType(
+                InformationType.PRIVATESECURITY, repo.owner)
+            job.start()
+            job.fail()
+            pending_with_failed_jobs.append(repo)
+
+        pending_with_started_job_repos = []
+        for i in range(2):
+            repo = self.factory.makeGitRepository()
+            job = repo.transitionToInformationType(
+                InformationType.PRIVATESECURITY, repo.owner)
+            job.start()
+            pending_with_started_job_repos.append(repo)
+
+        pending_with_waiting_jobs = []
+        for i in range(3):
+            repo = self.factory.makeGitRepository()
+            repo.transitionToInformationType(
+                InformationType.PRIVATESECURITY, repo.owner)
+            pending_with_started_job_repos.append(repo)
+
+        self.assertEqual(15, store.find(GitRepository).count())
+
+        self.runHourly(maximum_chunk_size=2)
+
+        switch_dbuser('testadmin')
+        self.assertEqual(15, store.find(GitRepository).count())
+        self.assertTrue(
+            all(i.status == available for i in created_repos))
+        self.assertTrue(
+            all(i.status == available for i in pending_without_job_repos))
+        self.assertTrue(
+            all(i.status == available for i in pending_with_failed_jobs))
+        self.assertTrue(
+            all(i.status == pending_transition
+                for i in pending_with_started_job_repos))
+        self.assertTrue(
+            all(i.status == pending_transition
+                for i in pending_with_waiting_jobs))
+
     def test_WebhookJobPruner(self):
         # Garbo should remove jobs completed over 30 days ago.
         switch_dbuser('testadmin')
diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
index aaa9d30..1a51d27 100644
--- a/lib/lp/services/config/schema-lazr.conf
+++ b/lib/lp/services/config/schema-lazr.conf
@@ -1768,6 +1768,7 @@ job_sources:
     ICommercialExpiredJobSource,
     IExpiringMembershipNotificationJobSource,
     IGitRepositoryModifiedMailJobSource,
+    IGitRepositoryTransitionToInformationTypeJobSource,
     IMembershipNotificationJobSource,
     IOCIRecipeRequestBuildsJobSource,
     IOCIRegistryUploadJobSource,
@@ -1829,6 +1830,11 @@ module: lp.code.interfaces.gitjob
 dbuser: send-branch-mail
 crontab_group: MAIN
 
+[IGitRepositoryTransitionToInformationTypeJobSource]
+module: lp.code.interfaces.gitjob
+dbuser: privacy-change-jobs
+crontab_group: MAIN
+
 [IInitializeDistroSeriesJobSource]
 module: lp.soyuz.interfaces.distributionjob
 dbuser: initializedistroseries
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 57babc5..9d1f53b 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -1815,7 +1815,7 @@ class BareLaunchpadObjectFactory(ObjectFactory):
             reviewer=reviewer, **optional_repository_args)
         naked_repository = removeSecurityProxy(repository)
         if information_type is not None:
-            naked_repository.transitionToInformationType(
+            naked_repository._transitionToInformationType(
                 information_type, registrant, verify_policy=False)
         return repository
 

References