← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~pappacena/launchpad:create-mp-refs into launchpad:master

 

Thiago F. Pappacena has proposed merging ~pappacena/launchpad:create-mp-refs into launchpad:master with ~pappacena/launchpad:githosting-copy-and-delete-refs as a prerequisite.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/390581
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/launchpad:create-mp-refs into launchpad:master.
diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py
index 344bc43..9e4c129 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 lits of refs copy requested to GitHosting.
         """
 
     def markRecipesStale(paths):
diff --git a/lib/lp/code/model/branchmergeproposal.py b/lib/lp/code/model/branchmergeproposal.py
index 2a346b4..8a29a99 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
 
@@ -83,7 +84,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
@@ -93,6 +99,7 @@ from lp.code.model.diff import (
     IncrementalDiff,
     PreviewDiff,
     )
+from lp.code.model.githosting import RefCopyOperation
 from lp.registry.interfaces.person import (
     IPerson,
     IPersonSet,
@@ -122,6 +129,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
@@ -143,6 +151,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?
 
@@ -968,6 +979,8 @@ class BranchMergeProposal(SQLBase, BugLinkTargetMixin):
             branch_merge_proposal=self.id):
             job.destroySelf()
         self._preview_diffs.remove()
+        self.deleteGitHostingVirtualRefs()
+
         self.destroySelf()
 
     def getUnlandedSourceBranchRevisions(self):
@@ -1214,6 +1227,66 @@ class BranchMergeProposal(SQLBase, BugLinkTargetMixin):
             for diff in diffs)
         return [diff_dict.get(revisions) for revisions in revision_list]
 
+    def getGitHostingRefCopyOperations(self):
+        """Gets the list of GitHosting copy operations that should be done
+        in order to keep this MP's virtual refs up-to-date."""
+        if (not self.target_git_repository
+                or not getFeatureFlag(GIT_CREATE_MP_VIRTUAL_REF)):
+            return []
+        # If the source repository is private and it's opening a MP to
+        # another repository, we should not risk to to have the private code
+        # undisclosed to everyone with permission to see the target repo:
+        private_source = self.source_git_repository.private
+        same_repo = self.source_git_repository == self.target_git_repository
+        same_owner = self.source_git_repository.owner.inTeam(
+            self.target_git_repository.owner)
+        if private_source and not (same_repo or same_owner):
+            return []
+        return [RefCopyOperation(
+            self.source_git_commit_sha1,
+            self.target_git_repository.getInternalPath(),
+            GIT_MP_VIRTUAL_REF_FORMAT.format(mp=self))]
+
+    def copyGitHostingVirtualRefs(self):
+        """Requests virtual refs copy operations on GitHosting in order to
+        keep them up-to-date with current MP's state.
+
+        :return: The list of copy operations requested."""
+        copy_operations = self.getGitHostingRefCopyOperations()
+        if copy_operations:
+            hosting_client = getUtility(IGitHostingClient)
+            hosting_client.copyRefs(
+                self.source_git_repository.getInternalPath(),
+                copy_operations)
+        return copy_operations
+
+    def deleteGitHostingVirtualRefs(self, except_refs=None):
+        """Deletes on git code hosting service all virtual refs, except
+        those ones in the given list."""
+        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
+        ref = GIT_MP_VIRTUAL_REF_FORMAT.format(mp=self)
+        if ref not in (except_refs or []):
+            hosting_client = getUtility(IGitHostingClient)
+            hosting_client.deleteRef(
+                self.target_git_repository.getInternalPath(), ref)
+
+    def syncGitHostingVirtualRefs(self):
+        """Requests all copies and deletion of virtual refs to make git code
+        hosting in sync with this MP."""
+        operations = self.copyGitHostingVirtualRefs()
+        copied_refs = [i.target_ref for i in operations]
+        self.deleteGitHostingVirtualRefs(except_refs=copied_refs)
+
     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 b957574..12a7d2f 100644
--- a/lib/lp/code/model/githosting.py
+++ b/lib/lp/code/model/githosting.py
@@ -32,7 +32,6 @@ from lp.code.errors import (
     GitRepositoryDeletionFault,
     GitRepositoryScanFault,
     GitTargetError,
-    NoSuchGitReference,
     )
 from lp.code.interfaces.githosting import IGitHostingClient
 from lp.services.config import config
@@ -289,6 +288,13 @@ class GitHostingClient:
             url = "/repo/%s/%s" % (path, ref)
             self._delete(url)
         except requests.RequestException as e:
+            if (e.response is not None and
+                    e.response.status_code == requests.codes.NOT_FOUND):
+                if logger is not None:
+                    logger.info(
+                        "Tried to delete a ref that doesn't exist: %s (%s)" %
+                        (path, ref))
+                return
             raise GitReferenceDeletionFault(
                 "Error deleting %s from repo %s: HTTP %s" %
                 (ref, path, e.response.status_code))
diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
index 3d2408e..e2451fa 100644
--- a/lib/lp/code/model/gitrepository.py
+++ b/lib/lp/code/model/gitrepository.py
@@ -1161,9 +1161,15 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
     def updateLandingTargets(self, paths):
         """See `IGitRepository`."""
         jobs = []
+        copy_operations = []
         for merge_proposal in self.getActiveLandingTargets(paths):
             jobs.extend(merge_proposal.scheduleDiffUpdates())
-        return jobs
+            copy_operations += merge_proposal.getGitHostingRefCopyOperations()
+
+        if copy_operations:
+            hosting_client = getUtility(IGitHostingClient)
+            hosting_client.copyRefs(self.getInternalPath(), copy_operations)
+        return jobs, copy_operations
 
     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 0d2457d..54cf516 100644
--- a/lib/lp/code/model/tests/test_branchmergeproposal.py
+++ b/lib/lp/code/model/tests/test_branchmergeproposal.py
@@ -66,6 +66,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,
@@ -96,6 +100,7 @@ from lp.testing import (
     ExpectedException,
     launchpadlib_for,
     login,
+    login_admin,
     login_person,
     person_logged_in,
     TestCaseWithFactory,
@@ -255,6 +260,136 @@ 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_copy_git_merge_virtual_ref(self):
+        self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"}))
+        mp = self.factory.makeBranchMergeProposalForGit()
+
+        copy_operations = mp.getGitHostingRefCopyOperations()
+        self.assertEqual(1, len(copy_operations))
+        self.assertThat(copy_operations[0], MatchesStructure(
+            source_ref=Equals(mp.source_git_commit_sha1),
+            target_repo=Equals(mp.target_git_repository.getInternalPath()),
+            target_ref=Equals("refs/merge/%s/head" % mp.id),
+        ))
+
+        self.assertEqual(1, self.hosting_fixture.copyRefs.call_count)
+        args, kwargs = self.hosting_fixture.copyRefs.calls[0]
+        arg_path, arg_copy_operations = args
+        self.assertEqual({}, kwargs)
+        self.assertEqual(mp.source_git_repository.getInternalPath(), arg_path)
+        self.assertEqual(1, len(arg_copy_operations))
+        self.assertThat(arg_copy_operations[0], MatchesStructure(
+            source_ref=Equals(mp.source_git_commit_sha1),
+            target_repo=Equals(mp.target_git_repository.getInternalPath()),
+            target_ref=Equals("refs/merge/%s/head" % mp.id),
+        ))
+
+    def test_getGitHostingRefCopyOperations_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.getGitHostingRefCopyOperations())
+
+    def test_getGitHostingRefCopyOperations_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.getGitHostingRefCopyOperations()
+        self.assertEqual(1, len(operations))
+        self.assertThat(operations[0], MatchesStructure(
+            source_ref=Equals(mp.source_git_commit_sha1),
+            target_repo=Equals(mp.target_git_repository.getInternalPath()),
+            target_ref=Equals(GIT_MP_VIRTUAL_REF_FORMAT.format(mp=mp))
+        ))
+
+    def test_getGitHostingRefCopyOperations_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.getGitHostingRefCopyOperations()
+        self.assertEqual(1, len(operations))
+        self.assertThat(operations[0], MatchesStructure(
+            source_ref=Equals(mp.source_git_commit_sha1),
+            target_repo=Equals(mp.target_git_repository.getInternalPath()),
+            target_ref=Equals(GIT_MP_VIRTUAL_REF_FORMAT.format(mp=mp))
+        ))
+
+    def test_syncGitHostingVirtualRefs(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.syncGitHostingVirtualRefs should have been triggered by event.
+        # Check lp.code.subscribers.branchmergeproposal.merge_proposal_created.
+        self.assertEqual(1, self.hosting_fixture.copyRefs.call_count)
+        args, kwargs = self.hosting_fixture.copyRefs.calls[0]
+        self.assertEquals({}, kwargs)
+        self.assertEqual(args[0], source_repo.getInternalPath())
+        self.assertEqual(1, len(args[1]))
+        self.assertThat(args[1][0], MatchesStructure(
+            source_ref=Equals(mp.source_git_commit_sha1),
+            target_repo=Equals(mp.target_git_repository.getInternalPath()),
+            target_ref=Equals("refs/merge/%s/head" % mp.id),
+        ))
+
+        self.assertEqual(0, self.hosting_fixture.deleteRef.call_count)
+
+    def test_syncGitHostingVirtualRefs_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.syncGitHostingVirtualRefs should have been triggered by event.
+        # Check lp.code.subscribers.branchmergeproposal.merge_proposal_created.
+        self.assertEqual(0, self.hosting_fixture.copyRefs.call_count)
+        self.assertEqual(1, self.hosting_fixture.deleteRef.call_count)
+        args, kwargs = self.hosting_fixture.deleteRef.calls[0]
+        self.assertEqual({}, kwargs)
+        self.assertEqual(
+            (target_repo.getInternalPath(), "refs/merge/%s/head" % mp.id),
+            args)
+
+
 class TestBranchMergeProposalTransitions(TestCaseWithFactory):
     """Test the state transitions of branch merge proposals."""
 
@@ -1184,6 +1319,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)
@@ -1204,8 +1343,11 @@ class TestMergeProposalWebhooks(WithVCSScenarios, TestCaseWithFactory):
             old=MatchesDict(self.getExpectedPayload(proposal, redact=True)))
         proposal.deleteProposal()
         delivery = hook.deliveries.one()
+        self.assertIsNotNone(delivery)
         self.assertCorrectDelivery(expected_payload, hook, delivery)
         self.assertCorrectLogging(expected_redacted_payload, hook, logger)
+        self.assertEqual(
+            1 if self.git else 0, hosting_fixture.deleteRef.call_count)
 
 
 class TestGetAddress(TestCaseWithFactory):
@@ -1506,6 +1648,18 @@ 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"}))
+        hosting_fixture = self.useFixture(GitHostingFixture())
+        proposal = self.factory.makeBranchMergeProposalForGit()
+        proposal.deleteProposal()
+
+        self.assertEqual(1, hosting_fixture.deleteRef.call_count)
+        args = hosting_fixture.deleteRef.calls[0]
+        self.assertEqual((
+            (proposal.target_git_repository.getInternalPath(),
+             'refs/merge/%s/head' % proposal.id), {}), args)
+
 
 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 04f7a8e..bacbd66 100644
--- a/lib/lp/code/model/tests/test_githosting.py
+++ b/lib/lp/code/model/tests/test_githosting.py
@@ -23,7 +23,6 @@ from lazr.restful.utils import get_current_browser_request
 import responses
 from six.moves.urllib.parse import (
     parse_qsl,
-    urljoin,
     urlsplit,
     )
 from testtools.matchers import (
@@ -43,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
diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py
index 62bf865..b0116e1 100644
--- a/lib/lp/code/model/tests/test_gitrepository.py
+++ b/lib/lp/code/model/tests/test_gitrepository.py
@@ -96,6 +96,7 @@ from lp.code.interfaces.gitnamespace import (
     IGitNamespaceSet,
     )
 from lp.code.interfaces.gitrepository import (
+    GIT_CREATE_MP_VIRTUAL_REF,
     IGitRepository,
     IGitRepositorySet,
     IGitRepositoryView,
@@ -782,6 +783,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.
@@ -823,7 +825,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
@@ -983,6 +984,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.
@@ -1114,7 +1116,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.
@@ -1125,7 +1126,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
@@ -1181,7 +1181,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
@@ -1520,6 +1519,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 = unicode(hashlib.sha1("").hexdigest())
@@ -1788,18 +1791,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
@@ -1871,9 +1873,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
@@ -1916,7 +1917,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,
@@ -1927,22 +1927,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(
@@ -1955,7 +1953,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):
@@ -2576,18 +2574,59 @@ 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(
+
+        # 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 = 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.copyRefs.call_count)
+        else:
+            self.assertEqual(1, self.hosting_fixture.copyRefs.call_count)
+            args, kwargs = self.hosting_fixture.copyRefs.calls[0]
+            self.assertEqual({}, kwargs)
+            self.assertEqual(2, len(args))
+            path, operations = args
+            self.assertEqual(
+                path, bmp1.source_git_repository.getInternalPath())
+            self.assertThat(operations[0], MatchesStructure(
+                source_ref=Equals(bmp1.source_git_commit_sha1),
+                target_repo=Equals(
+                    bmp1.target_git_repository.getInternalPath()),
+                target_ref=Equals("refs/merge/%s/head" % bmp1.id),
+            ))
+            self.assertThat(operations[1], MatchesStructure(
+                source_ref=Equals(bmp2.source_git_commit_sha1),
+                target_repo=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(True)
+
+    def test_schedules_diff_updates_without_mp_virtual_ref(self):
+        self.assertSchedulesDiffUpdate(False)
+
     def test_ignores_final(self):
         """Diffs for proposals in final states aren't updated."""
         [source_ref] = self.factory.makeGitRefs()
@@ -2599,8 +2638,15 @@ 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 = source_ref.repository.updateLandingTargets(
+            [source_ref.path])
         self.assertEqual(0, len(jobs))
+        self.assertEqual(0, len(ref_copies))
+        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..b394388 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.syncGitHostingVirtualRefs()
+
     if getFeatureFlag(BRANCH_MERGE_PROPOSAL_WEBHOOKS_FEATURE_FLAG):
         payload = {
             "action": "created",
diff --git a/lib/lp/code/tests/helpers.py b/lib/lp/code/tests/helpers.py
index a4f8416..fb21ee9 100644
--- a/lib/lp/code/tests/helpers.py
+++ b/lib/lp/code/tests/helpers.py
@@ -369,6 +369,8 @@ class GitHostingFixture(fixtures.Fixture):
         self.getBlob = FakeMethod(result=blob)
         self.delete = FakeMethod()
         self.disable_memcache = disable_memcache
+        self.copyRefs = FakeMethod()
+        self.deleteRef = FakeMethod()
 
     def _setUp(self):
         self.useFixture(ZopeUtilityFixture(self, IGitHostingClient))

Follow ups