← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/code-async-delete into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/code-async-delete into lp:launchpad with lp:~cjwatson/launchpad/git-repository-delete-job as a prerequisite.

Commit message:
Delete branches and Git repositories asynchronously.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1793266 in Launchpad itself: "Unable to delete repository"
  https://bugs.launchpad.net/launchpad/+bug/1793266

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/code-async-delete/+merge/364912
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/code-async-delete into lp:launchpad.
=== modified file 'lib/lp/code/browser/branch.py'
--- lib/lp/code/browser/branch.py	2019-01-30 16:41:12 +0000
+++ lib/lp/code/browser/branch.py	2019-03-21 16:27:30 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Branch views."""
@@ -87,7 +87,10 @@
 from lp.code.browser.decorations import DecoratedBranch
 from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin
 from lp.code.browser.widgets.branchtarget import BranchTargetWidget
-from lp.code.enums import BranchType
+from lp.code.enums import (
+    BranchDeletionStatus,
+    BranchType,
+    )
 from lp.code.errors import (
     BranchCreationForbidden,
     BranchExists,
@@ -254,7 +257,8 @@
     @enabled_with_permission('launchpad.Edit')
     def delete(self):
         text = 'Delete branch'
-        return Link('+delete', text, icon='trash-icon')
+        enabled = self.context.deletion_status != BranchDeletionStatus.DELETING
+        return Link('+delete', text, icon='trash-icon', enabled=enabled)
 
     @enabled_with_permission('launchpad.AnyPerson')
     def edit_whiteboard(self):
@@ -992,7 +996,7 @@
             # somewhere valid to send them next.
             self.next_url = canonical_url(branch.target)
             message = "Branch %s deleted." % branch.unique_name
-            self.context.destroySelf(break_references=True)
+            self.context.delete()
             self.request.response.addNotification(message)
         else:
             self.request.response.addNotification(

=== modified file 'lib/lp/code/browser/gitrepository.py'
--- lib/lp/code/browser/gitrepository.py	2019-01-30 16:41:12 +0000
+++ lib/lp/code/browser/gitrepository.py	2019-03-21 16:27:30 +0000
@@ -87,6 +87,7 @@
     )
 from lp.code.enums import (
     GitGranteeType,
+    GitRepositoryDeletionStatus,
     GitRepositoryType,
     )
 from lp.code.errors import (
@@ -276,7 +277,10 @@
     @enabled_with_permission("launchpad.Edit")
     def delete(self):
         text = "Delete repository"
-        return Link("+delete", text, icon="trash-icon")
+        enabled = (
+            self.context.deletion_status !=
+            GitRepositoryDeletionStatus.DELETING)
+        return Link("+delete", text, icon="trash-icon", enabled=enabled)
 
 
 class GitRepositoryContextMenu(ContextMenu, HasRecipesMenuMixin):
@@ -1294,7 +1298,7 @@
             # have somewhere valid to send them next.
             self.next_url = canonical_url(repository.target)
             message = "Repository %s deleted." % repository.unique_name
-            self.context.destroySelf(break_references=True)
+            self.context.delete()
             self.request.response.addNotification(message)
         else:
             self.request.response.addNotification(

=== modified file 'lib/lp/code/browser/tests/test_gitrepository.py'
--- lib/lp/code/browser/tests/test_gitrepository.py	2019-01-30 17:24:15 +0000
+++ lib/lp/code/browser/tests/test_gitrepository.py	2019-03-21 16:27:30 +0000
@@ -16,6 +16,7 @@
 from textwrap import dedent
 
 from fixtures import FakeLogger
+from mechanize import LinkNotFoundError
 import pytz
 import soupmatchers
 from storm.store import Store
@@ -1829,6 +1830,25 @@
             ["Repository %s deleted." % name],
             get_feedback_messages(browser.contents))
 
+    def test_deleting_repository(self):
+        # Repository deletion is asynchronous.  If the user finds the
+        # repository again before the deletion completes, then they see an
+        # indication that it is being deleted, and there is no "Delete
+        # repository" action.
+        owner = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(owner=owner)
+        with person_logged_in(repository.owner):
+            repository.delete()
+        self.assertEndsWith(repository.unique_name, "-deleting")
+        browser = self.getViewBrowser(
+            repository, "+index", rootsite="code", user=repository.owner)
+        self.assertEqual(
+            "This repository is being deleted.",
+            extract_text(find_tag_by_id(
+                browser.contents, "repository-deleting")))
+        self.assertRaises(
+            LinkNotFoundError, browser.getLink, "Delete repository")
+
 
 class TestGitRepositoryActivityView(BrowserTestCase):
 

=== modified file 'lib/lp/code/interfaces/branch.py'
--- lib/lp/code/interfaces/branch.py	2019-03-21 16:27:29 +0000
+++ lib/lp/code/interfaces/branch.py	2019-03-21 16:27:30 +0000
@@ -1228,9 +1228,6 @@
             branch.
         """
 
-    @call_with(break_references=True)
-    @export_destructor_operation()
-    @operation_for_version('beta')
     def destroySelf(break_references=False):
         """Delete the specified branch.
 
@@ -1239,7 +1236,23 @@
         :param break_references: If supplied, break any references to this
             branch by deleting items with mandatory references and
             NULLing other references.
-        :raise: CannotDeleteBranch if the branch cannot be deleted.
+        :raises CannotDeleteBranch: if the branch cannot be deleted.
+        """
+
+    @export_destructor_operation()
+    @operation_for_version('beta')
+    def delete():
+        """Request deletion of the specified branch.
+
+        The branch will be deleted asynchronously.
+
+        This will delete any merge proposals that use this branch as their
+        source or target, and any recipes that use this branch.  It will
+        clear the prerequisite from any merge proposals that use this branch
+        as a prerequisite.  It will detach the branch from any snap packages
+        that build from it.
+
+        :raises CannotDeleteBranch: if the branch cannot be deleted.
         """
 
 

=== modified file 'lib/lp/code/interfaces/gitrepository.py'
--- lib/lp/code/interfaces/gitrepository.py	2019-03-21 16:27:29 +0000
+++ lib/lp/code/interfaces/gitrepository.py	2019-03-21 16:27:30 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Git repository interfaces."""
@@ -881,16 +881,29 @@
             object needs to be touched.
         """
 
-    @call_with(break_references=True)
-    @export_destructor_operation()
-    @operation_for_version("devel")
     def destroySelf(break_references=False):
         """Delete the specified repository.
 
         :param break_references: If supplied, break any references to this
             repository by deleting items with mandatory references and
             NULLing other references.
-        :raise: CannotDeleteGitRepository if the repository cannot be deleted.
+        :raises CannotDeleteGitRepository: if the repository cannot be deleted.
+        """
+
+    @export_destructor_operation()
+    @operation_for_version("devel")
+    def delete():
+        """Request deletion of the specified repository.
+
+        The repository will be deleted asynchronously.
+
+        This will delete any merge proposals that use this repository as
+        their source or target, and any recipes that use this repository.
+        It will clear the prerequisite from any merge proposals that use
+        this repository as a prerequisite.  It will detach the repository
+        from any snap packages that build from it.
+
+        :raises CannotDeleteGitRepository: if the repository cannot be deleted.
         """
 
 

=== modified file 'lib/lp/code/model/branch.py'
--- lib/lp/code/model/branch.py	2019-03-21 16:27:29 +0000
+++ lib/lp/code/model/branch.py	2019-03-21 16:27:30 +0000
@@ -112,6 +112,11 @@
     )
 from lp.code.interfaces.branchcollection import IAllBranches
 from lp.code.interfaces.branchhosting import IBranchHostingClient
+from lp.code.interfaces.branchjob import (
+    IBranchDeleteJobSource,
+    IBranchUpgradeJobSource,
+    IReclaimBranchSpaceJobSource,
+    )
 from lp.code.interfaces.branchlookup import IBranchLookup
 from lp.code.interfaces.branchmergeproposal import (
     BRANCH_MERGE_PROPOSAL_FINAL_STATES,
@@ -1455,7 +1460,6 @@
 
     def destroySelf(self, break_references=False):
         """See `IBranch`."""
-        from lp.code.interfaces.branchjob import IReclaimBranchSpaceJobSource
         if break_references:
             self._breakReferences()
         if not self.canBeDeleted():
@@ -1473,6 +1477,22 @@
         job = getUtility(IReclaimBranchSpaceJobSource).create(branch_id)
         job.celeryRunOnCommit()
 
+    def delete(self):
+        """See `IBranch`."""
+        if self.deletion_status == BranchDeletionStatus.DELETING:
+            raise CannotDeleteBranch(
+                "This branch is already being deleted: %s" % self.unique_name)
+        if not self.getStackedBranches().is_empty():
+            # If there are branches stacked on this one, then we won't be
+            # able to delete this branch even after breaking references.
+            raise CannotDeleteBranch(
+                "Cannot delete branch: %s" % self.unique_name)
+        # Destructor operations can't take arguments.
+        user = getUtility(ILaunchBag).user
+        getUtility(IBranchDeleteJobSource).create(self, user)
+        self.name = self.namespace.findUnusedName(self.name + "-deleting")
+        self.deletion_status = BranchDeletionStatus.DELETING
+
     def checkUpgrade(self):
         if self.branch_type is not BranchType.HOSTED:
             raise CannotUpgradeNonHosted(self)
@@ -1509,7 +1529,6 @@
 
     def requestUpgrade(self, requester):
         """See `IBranch`."""
-        from lp.code.interfaces.branchjob import IBranchUpgradeJobSource
         job = getUtility(IBranchUpgradeJobSource).create(self, requester)
         job.celeryRunOnCommit()
         return job

=== modified file 'lib/lp/code/model/gitrepository.py'
--- lib/lp/code/model/gitrepository.py	2019-03-21 16:27:29 +0000
+++ lib/lp/code/model/gitrepository.py	2019-03-21 16:27:30 +0000
@@ -112,7 +112,11 @@
     IGitCollection,
     )
 from lp.code.interfaces.githosting import IGitHostingClient
-from lp.code.interfaces.gitjob import IGitRefScanJobSource
+from lp.code.interfaces.gitjob import (
+    IGitRefScanJobSource,
+    IGitRepositoryDeleteJobSource,
+    IReclaimGitRepositorySpaceJobSource,
+    )
 from lp.code.interfaces.gitlookup import IGitLookup
 from lp.code.interfaces.gitnamespace import (
     get_git_namespace,
@@ -1551,11 +1555,6 @@
 
     def destroySelf(self, break_references=False):
         """See `IGitRepository`."""
-        # Circular import.
-        from lp.code.interfaces.gitjob import (
-            IReclaimGitRepositorySpaceJobSource,
-            )
-
         if break_references:
             self._breakReferences()
         if not self.canBeDeleted():
@@ -1583,6 +1582,18 @@
         getUtility(IReclaimGitRepositorySpaceJobSource).create(
             repository_name, repository_path)
 
+    def delete(self):
+        """See `IGitRepository`."""
+        if self.deletion_status == GitRepositoryDeletionStatus.DELETING:
+            raise CannotDeleteGitRepository(
+                "This repository is already being deleted: %s" %
+                self.unique_name)
+        # Destructor operations can't take arguments.
+        user = getUtility(ILaunchBag).user
+        getUtility(IGitRepositoryDeleteJobSource).create(self, user)
+        self.name = self.namespace.findUnusedName(self.name + "-deleting")
+        self.deletion_status = GitRepositoryDeletionStatus.DELETING
+
 
 class DeletionOperation:
     """Represent an operation to perform as part of branch deletion."""

=== modified file 'lib/lp/code/model/tests/test_branch.py'
--- lib/lp/code/model/tests/test_branch.py	2019-03-21 16:27:29 +0000
+++ lib/lp/code/model/tests/test_branch.py	2019-03-21 16:27:30 +0000
@@ -53,6 +53,7 @@
     RepositoryFormat,
     )
 from lp.code.enums import (
+    BranchDeletionStatus,
     BranchLifecycleStatus,
     BranchSubscriptionNotificationLevel,
     BranchType,
@@ -74,6 +75,7 @@
     IBranch,
     )
 from lp.code.interfaces.branchjob import (
+    IBranchDeleteJobSource,
     IBranchScanJobSource,
     IBranchUpgradeJobSource,
     )
@@ -1483,6 +1485,34 @@
         transaction.commit()
         self.assertRaises(LostObjectError, getattr, webhook, 'target')
 
+    def test_delete_schedules_job(self):
+        # Calling the delete method schedules a deletion job, which can be
+        # processed.
+        branch_id = self.branch.id
+        branch_name = self.branch.unique_name
+        self.branch.delete()
+        update_trigger_modified_fields(self.branch)
+        self.assertEqual(branch_name + "-deleting", self.branch.unique_name)
+        self.assertEqual(
+            BranchDeletionStatus.DELETING, self.branch.deletion_status)
+        [job] = getUtility(IBranchDeleteJobSource).iterReady()
+        self.assertEqual(branch_id, job.branch_id)
+        self.assertEqual(self.user, job.requester)
+        with dbuser(config.IBranchDeleteJobSource.dbuser):
+            JobRunner([job]).runAll()
+
+    def test_delete_already_deleting(self):
+        # Trying to delete a branch that is already being deleted raises
+        # CannotDeleteBranch.
+        self.branch.delete()
+        self.assertRaises(CannotDeleteBranch, self.branch.delete)
+
+    def test_delete_stacked_branches(self):
+        # Trying to delete a branch that is stacked upon raises
+        # CannotDeleteBranch.
+        self.factory.makeAnyBranch(stacked_on=self.branch)
+        self.assertRaises(CannotDeleteBranch, self.branch.delete)
+
 
 class TestBranchDeletionConsequences(TestCase):
     """Test determination and application of branch deletion consequences."""

=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
--- lib/lp/code/model/tests/test_gitrepository.py	2019-03-21 16:27:29 +0000
+++ lib/lp/code/model/tests/test_gitrepository.py	2019-03-21 16:27:30 +0000
@@ -52,6 +52,7 @@
     CodeReviewNotificationLevel,
     GitGranteeType,
     GitObjectType,
+    GitRepositoryDeletionStatus,
     GitRepositoryType,
     TargetRevisionControlSystems,
     )
@@ -71,6 +72,7 @@
 from lp.code.interfaces.defaultgit import ICanHasDefaultGitRepository
 from lp.code.interfaces.gitjob import (
     IGitRefScanJobSource,
+    IGitRepositoryDeleteJobSource,
     IGitRepositoryModifiedMailJobSource,
     )
 from lp.code.interfaces.gitlookup import IGitLookup
@@ -818,6 +820,29 @@
             GitActivity, GitActivity.repository_id == repository_id)
         self.assertEqual([], list(activities))
 
+    def test_delete_schedules_job(self):
+        # Calling the delete method schedules a deletion job, which can be
+        # processed.
+        repository_id = self.repository.id
+        repository_name = self.repository.unique_name
+        self.repository.delete()
+        self.assertEqual(
+            repository_name + "-deleting", self.repository.unique_name)
+        self.assertEqual(
+            GitRepositoryDeletionStatus.DELETING,
+            self.repository.deletion_status)
+        [job] = getUtility(IGitRepositoryDeleteJobSource).iterReady()
+        self.assertEqual(repository_id, job.repository_id)
+        self.assertEqual(self.user, job.requester)
+        with dbuser(config.IGitRepositoryDeleteJobSource.dbuser):
+            JobRunner([job]).runAll()
+
+    def test_delete_already_deleting(self):
+        # Trying to delete a repository that is already being deleted raises
+        # CannotDeleteGitRepository.
+        self.repository.delete()
+        self.assertRaises(CannotDeleteGitRepository, self.repository.delete)
+
 
 class TestGitRepositoryDeletionConsequences(TestCaseWithFactory):
     """Test determination and application of repository deletion

=== modified file 'lib/lp/code/stories/branches/xx-branch-deletion.txt'
--- lib/lp/code/stories/branches/xx-branch-deletion.txt	2018-05-13 10:35:52 +0000
+++ lib/lp/code/stories/branches/xx-branch-deletion.txt	2019-03-21 16:27:30 +0000
@@ -52,6 +52,20 @@
     >>> print_feedback_messages(browser.contents)
     Branch ~alice/earthlynx/to-delete deleted...
 
+Branch deletion is asynchronous.  If the user finds the branch again before
+the deletion completes, then they see an indication that it is being
+deleted, and there is no 'Delete branch' action.
+
+    >>> browser.open(
+    ...     'http://code.launchpad.dev/~alice/earthlynx/to-delete-deleting')
+    >>> print(extract_text(find_tag_by_id(
+    ...     browser.contents, 'branch-deleting')))
+    This branch is being deleted.
+    >>> browser.getLink('Delete branch')
+    Traceback (most recent call last):
+    ...
+    LinkNotFoundError
+
 If the branch is junk, then the user is taken back to the code listing for
 the deleted branch's owner.
 

=== modified file 'lib/lp/code/stories/webservice/xx-branch.txt'
--- lib/lp/code/stories/webservice/xx-branch.txt	2019-03-21 16:27:29 +0000
+++ lib/lp/code/stories/webservice/xx-branch.txt	2019-03-21 16:27:30 +0000
@@ -300,4 +300,17 @@
 
     >>> print_branches(webservice, '/widgets')
     ~eric/fooix/trunk - Development
-
+    ~eric/fooix/feature-branch-deleting - Experimental
+    ~mary/blob/bar-deleting - Development
+
+    >>> from zope.component import getUtility
+    >>> from lp.code.interfaces.branchjob import IBranchDeleteJobSource
+    >>> from lp.services.config import config
+    >>> from lp.services.job.runner import JobRunner
+
+    >>> with permissive_security_policy(config.IBranchDeleteJobSource.dbuser):
+    ...     job_source = getUtility(IBranchDeleteJobSource)
+    ...     JobRunner.fromReady(job_source).runAll()
+
+    >>> print_branches(webservice, '/widgets')
+    ~eric/fooix/trunk - Development

=== modified file 'lib/lp/code/templates/branch-index.pt'
--- lib/lp/code/templates/branch-index.pt	2019-01-23 17:52:34 +0000
+++ lib/lp/code/templates/branch-index.pt	2019-03-21 16:27:30 +0000
@@ -67,6 +67,10 @@
 <div metal:fill-slot="main">
 
   <div class="yui-g first">
+    <div tal:condition="python: context.deletion_status.name == 'DELETING'"
+         id="branch-deleting" class="warning message">
+      This branch is being deleted.
+    </div>
     <tal:branch-errors tal:replace="structure context/@@+messages" />
   </div>
 

=== modified file 'lib/lp/code/templates/gitrepository-index.pt'
--- lib/lp/code/templates/gitrepository-index.pt	2019-01-28 15:36:56 +0000
+++ lib/lp/code/templates/gitrepository-index.pt	2019-03-21 16:27:30 +0000
@@ -33,6 +33,13 @@
 
 <div metal:fill-slot="main">
 
+  <div class="yui-g first">
+    <div tal:condition="python: context.deletion_status.name == 'DELETING'"
+         id="repository-deleting" class="warning message">
+      This repository is being deleted.
+    </div>
+  </div>
+
   <div id="repository-description" tal:condition="context/description"
        class="summary"
        tal:content="structure context/description/fmt:text-to-html" />

=== modified file 'lib/lp/scripts/garbo.py'
--- lib/lp/scripts/garbo.py	2019-01-30 11:23:33 +0000
+++ lib/lp/scripts/garbo.py	2019-03-21 16:27:30 +0000
@@ -40,6 +40,7 @@
     Or,
     Row,
     SQL,
+    Update,
     )
 from storm.info import ClassAlias
 from storm.store import EmptyResultSet
@@ -57,13 +58,19 @@
     BugWatchScheduler,
     MAX_SAMPLE_SIZE,
     )
+from lp.code.enums import (
+    BranchDeletionStatus,
+    GitRepositoryDeletionStatus,
+    )
 from lp.code.interfaces.revision import IRevisionSet
+from lp.code.model.branch import Branch
 from lp.code.model.codeimportevent import CodeImportEvent
 from lp.code.model.codeimportresult import CodeImportResult
 from lp.code.model.diff import (
     Diff,
     PreviewDiff,
     )
+from lp.code.model.gitrepository import GitRepository
 from lp.code.model.revision import (
     RevisionAuthor,
     RevisionCache,
@@ -1611,6 +1618,62 @@
         """ % (SnapBuildJobType.STORE_UPLOAD.value, JobStatus.COMPLETED.value)
 
 
+class BranchDeletionStatusPopulator(TunableLoop):
+    """Populates Branch.deletion_status with ACTIVE."""
+
+    maximum_chunk_size = 5000
+
+    def __init__(self, log, abort_time=None):
+        super(BranchDeletionStatusPopulator, self).__init__(log, abort_time)
+        self.start_at = 1
+        self.store = IMasterStore(Branch)
+
+    def findBranches(self):
+        return self.store.find(
+            Branch,
+            Branch.id >= self.start_at,
+            Branch._deletion_status == None).order_by(Branch.id)
+
+    def isDone(self):
+        return self.findBranches().is_empty()
+
+    def __call__(self, chunk_size):
+        ids = [branch.id for branch in self.findBranches()]
+        self.store.execute(Update(
+            {Branch._deletion_status: BranchDeletionStatus.ACTIVE.value},
+            where=Branch.id.is_in(ids), table=Branch))
+        transaction.commit()
+
+
+class GitRepositoryDeletionStatusPopulator(TunableLoop):
+    """Populates GitRepository.deletion_status with ACTIVE."""
+
+    maximum_chunk_size = 5000
+
+    def __init__(self, log, abort_time=None):
+        super(GitRepositoryDeletionStatusPopulator, self).__init__(
+            log, abort_time)
+        self.start_at = 1
+        self.store = IMasterStore(GitRepository)
+
+    def findRepositories(self):
+        return self.store.find(
+            GitRepository,
+            GitRepository.id >= self.start_at,
+            GitRepository._deletion_status == None).order_by(GitRepository.id)
+
+    def isDone(self):
+        return self.findRepositories().is_empty()
+
+    def __call__(self, chunk_size):
+        ids = [branch.id for branch in self.findRepositories()]
+        self.store.execute(Update(
+            {GitRepository._deletion_status:
+             GitRepositoryDeletionStatus.ACTIVE.value},
+            where=GitRepository.id.is_in(ids), table=GitRepository))
+        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.
@@ -1884,6 +1947,7 @@
     script_name = 'garbo-daily'
     tunable_loops = [
         AnswerContactPruner,
+        BranchDeletionStatusPopulator,
         BranchJobPruner,
         BugNotificationPruner,
         BugWatchActivityPruner,
@@ -1891,6 +1955,7 @@
         CodeImportResultPruner,
         DiffPruner,
         GitJobPruner,
+        GitRepositoryDeletionStatusPopulator,
         HWSubmissionEmailLinker,
         LiveFSFilePruner,
         LoginTokenPruner,

=== modified file 'lib/lp/scripts/tests/test_garbo.py'
--- lib/lp/scripts/tests/test_garbo.py	2019-01-30 11:23:33 +0000
+++ lib/lp/scripts/tests/test_garbo.py	2019-03-21 16:27:30 +0000
@@ -15,6 +15,7 @@
 from StringIO import StringIO
 import time
 
+from psycopg2 import IntegrityError
 from pytz import UTC
 from storm.exceptions import LostObjectError
 from storm.expr import (
@@ -54,7 +55,11 @@
     BranchFormat,
     RepositoryFormat,
     )
-from lp.code.enums import CodeImportResultStatus
+from lp.code.enums import (
+    BranchDeletionStatus,
+    CodeImportResultStatus,
+    GitRepositoryDeletionStatus,
+    )
 from lp.code.interfaces.codeimportevent import ICodeImportEventSet
 from lp.code.interfaces.gitrepository import IGitRepositorySet
 from lp.code.model.branchjob import (
@@ -1606,6 +1611,84 @@
         # retained.
         self._test_SnapFilePruner('foo.snap', None, 30, expected_count=1)
 
+    def test_BranchDeletionStatusPopulator(self):
+        switch_dbuser('testadmin')
+        old_branches = [self.factory.makeAnyBranch() for _ in range(2)]
+        for branch in old_branches:
+            removeSecurityProxy(branch)._deletion_status = None
+        try:
+            Store.of(old_branches[0]).flush()
+        except IntegrityError:
+            # Now enforced by DB NOT NULL constraint; backfilling is no
+            # longer necessary.
+            return
+        active_branches = [self.factory.makeAnyBranch() for _ in range(2)]
+        for branch in active_branches:
+            removeSecurityProxy(branch)._deletion_status = (
+                BranchDeletionStatus.ACTIVE)
+        deleting_branches = [self.factory.makeAnyBranch() for _ in range(2)]
+        for branch in deleting_branches:
+            removeSecurityProxy(branch)._deletion_status = (
+                BranchDeletionStatus.DELETING)
+        transaction.commit()
+
+        self.runDaily()
+
+        # Old branches are backfilled.
+        for branch in old_branches:
+            self.assertEqual(
+                BranchDeletionStatus.ACTIVE,
+                removeSecurityProxy(branch)._deletion_status)
+        # Other branches are left alone.
+        for branch in active_branches:
+            self.assertEqual(
+                BranchDeletionStatus.ACTIVE,
+                removeSecurityProxy(branch)._deletion_status)
+        for branch in deleting_branches:
+            self.assertEqual(
+                BranchDeletionStatus.DELETING,
+                removeSecurityProxy(branch)._deletion_status)
+
+    def test_GitRepositoryDeletionStatusPopulator(self):
+        switch_dbuser('testadmin')
+        old_repositories = [self.factory.makeGitRepository() for _ in range(2)]
+        for repository in old_repositories:
+            removeSecurityProxy(repository)._deletion_status = None
+        try:
+            Store.of(old_repositories[0]).flush()
+        except IntegrityError:
+            # Now enforced by DB NOT NULL constraint; backfilling is no
+            # longer necessary.
+            return
+        active_repositories = [
+            self.factory.makeGitRepository() for _ in range(2)]
+        for repository in active_repositories:
+            removeSecurityProxy(repository)._deletion_status = (
+                GitRepositoryDeletionStatus.ACTIVE)
+        deleting_repositories = [
+            self.factory.makeGitRepository() for _ in range(2)]
+        for repository in deleting_repositories:
+            removeSecurityProxy(repository)._deletion_status = (
+                GitRepositoryDeletionStatus.DELETING)
+        transaction.commit()
+
+        self.runDaily()
+
+        # Old repositories are backfilled.
+        for repository in old_repositories:
+            self.assertEqual(
+                GitRepositoryDeletionStatus.ACTIVE,
+                removeSecurityProxy(repository)._deletion_status)
+        # Other repositories are left alone.
+        for repository in active_repositories:
+            self.assertEqual(
+                GitRepositoryDeletionStatus.ACTIVE,
+                removeSecurityProxy(repository)._deletion_status)
+        for repository in deleting_repositories:
+            self.assertEqual(
+                GitRepositoryDeletionStatus.DELETING,
+                removeSecurityProxy(repository)._deletion_status)
+
 
 class TestGarboTasks(TestCaseWithFactory):
     layer = LaunchpadZopelessLayer


Follow ups