← 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 with ~pappacena/launchpad:git-repo-async-provacy-garbo as a prerequisite.

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/393075

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 290e7bc..94cb548 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -2083,7 +2083,7 @@ public.xref                             = SELECT, INSERT, DELETE
 type=user
 
 [privacy-change-jobs]
-groups=script
+groups=script, branchscanner
 public.accessartifact                   = SELECT, UPDATE, DELETE, INSERT
 public.accessartifactgrant              = SELECT, UPDATE, DELETE, INSERT
 public.accesspolicyartifact             = SELECT, UPDATE, DELETE, INSERT
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/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/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 ba7b9f2..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,7 +34,10 @@ from zope.interface import (
     provider,
     )
 
-from lp.app.enums import InformationType
+from lp.app.enums import (
+    InformationType,
+    PRIVATE_INFORMATION_TYPES,
+    )
 from lp.app.errors import NotFoundError
 from lp.code.enums import (
     GitActivityType,
@@ -440,6 +444,18 @@ class GitRepositoryTransitionToInformationTypeJob(GitJobDerived):
     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 !=
@@ -447,7 +463,17 @@ class GitRepositoryTransitionToInformationTypeJob(GitJobDerived):
             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/gitrepository.py b/lib/lp/code/model/gitrepository.py
index fb506f1..731ca3f 100644
--- a/lib/lp/code/model/gitrepository.py
+++ b/lib/lp/code/model/gitrepository.py
@@ -1205,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_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_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py
index 08887f5..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.
@@ -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):
@@ -2581,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()
@@ -2604,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):
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))

References