Prune stale branches from mergers

Ensure that when a remote branch disappears, mergers prune that
branch from their local copy.  Otherwise, they will continue
to instruct executors to create that branch for jobs.  This is
not only incorrect, but it could lead to errors if the executors
don't have those objects locally.

The zuul-cloner used to use the merger.prune method to avoid this
issue, however, a straightforward application of that on the mergers
could be problematic.  If the merger's git repo is not in a coherent
state, the prune method will raise an error.  Further, at the end
of the reset() method, the merger actually leaves the repos in a
state where 'git prune' can not operate -- it leaves HEAD pointing
to a symbolic reference which may not exist as a head ref.  This
generally isn't an issue as the next thing that happens after reset()
is usually checkout.  But it does prevent us from running prune() at
the end of reset().

To address both of these issues, incorporate pruning into the reset
method in a safe manner.  First, create or update any non-stale
branches.  Pick the first of those to be the new local HEAD.  Reset
the repository to that.  Finally, delete any stale branches.

This change also updates all of the tests so that git clones of the
"upstream" repos use file:// urls, so that they do not rely on
hard links and more closely mimic actual behavior.  That was necessary
in this test in order to reproduce the situation where a merger
whch clones from upstream after a branch deletion would not have
objects that another merger which had cloned before deletion did have.

It also removes some test-specific debugging around git repo creation
which is no longer needed.

Change-Id: Ic34453ddb0205ad31708d154d53915d66d26b548
diff --git a/tests/base.py b/tests/base.py
index c449242..fe01399 100755
--- a/tests/base.py
+++ b/tests/base.py
@@ -606,7 +606,7 @@
         return ret
 
     def getGitUrl(self, project):
-        return os.path.join(self.upstream_root, project.name)
+        return 'file://' + os.path.join(self.upstream_root, project.name)
 
 
 class GithubChangeReference(git.Reference):
@@ -1794,18 +1794,6 @@
         else:
             self._log_stream = sys.stdout
 
-        # NOTE(jeblair): this is temporary extra debugging to try to
-        # track down a possible leak.
-        orig_git_repo_init = git.Repo.__init__
-
-        def git_repo_init(myself, *args, **kw):
-            orig_git_repo_init(myself, *args, **kw)
-            self.log.debug("Created git repo 0x%x %s" %
-                           (id(myself), repr(myself)))
-
-        self.useFixture(fixtures.MonkeyPatch('git.Repo.__init__',
-                                             git_repo_init))
-
         handler = logging.StreamHandler(self._log_stream)
         formatter = logging.Formatter('%(asctime)s %(name)-32s '
                                       '%(levelname)-8s %(message)s')
@@ -1960,6 +1948,9 @@
         self.config.set(
             'executor', 'command_socket',
             os.path.join(self.test_root, 'executor.socket'))
+        self.config.set(
+            'merger', 'command_socket',
+            os.path.join(self.test_root, 'merger.socket'))
 
         self.statsd = FakeStatsd()
         if self.config.has_section('statsd'):
@@ -2016,6 +2007,7 @@
             self.config, self.sched)
         self.merge_client = zuul.merger.client.MergeClient(
             self.config, self.sched)
+        self.merge_server = None
         self.nodepool = zuul.nodepool.Nodepool(self.sched)
         self.zk = zuul.zk.ZooKeeper()
         self.zk.connect(self.zk_config)
@@ -2290,6 +2282,8 @@
         self.executor_server.release()
         self.executor_client.stop()
         self.merge_client.stop()
+        if self.merge_server:
+            self.merge_server.stop()
         self.executor_server.stop()
         self.sched.stop()
         self.sched.join()
@@ -2362,6 +2356,13 @@
         zuul.merger.merger.reset_repo_to_head(repo)
         repo.git.clean('-x', '-f', '-d')
 
+    def delete_branch(self, project, branch):
+        path = os.path.join(self.upstream_root, project)
+        repo = git.Repo(path)
+        repo.head.reference = repo.heads['master']
+        zuul.merger.merger.reset_repo_to_head(repo)
+        repo.delete_head(repo.heads[branch], force=True)
+
     def create_commit(self, project):
         path = os.path.join(self.upstream_root, project)
         repo = git.Repo(path)
diff --git a/tests/fixtures/layouts/branch-deletion.yaml b/tests/fixtures/layouts/branch-deletion.yaml
new file mode 100644
index 0000000..f72902a
--- /dev/null
+++ b/tests/fixtures/layouts/branch-deletion.yaml
@@ -0,0 +1,34 @@
+- pipeline:
+    name: check
+    manager: independent
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        Verified: 1
+    failure:
+      gerrit:
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
+    run: playbooks/base.yaml
+
+- job:
+    name: project-test1
+    parent: base
+    branches: master
+
+- job:
+    name: project-test2
+    parent: base
+    branches: stable
+
+- project:
+    name: org/project
+    check:
+      jobs:
+        - project-test1
+        - project-test2
diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py
index 5db20b3..3d76510 100755
--- a/tests/unit/test_scheduler.py
+++ b/tests/unit/test_scheduler.py
@@ -161,6 +161,44 @@
         self.assertEqual(self.getJobFromHistory('project-test1').node,
                          'label2')
 
+    @simple_layout('layouts/branch-deletion.yaml')
+    def test_branch_deletion(self):
+        "Test the correct variant of a job runs on a branch"
+        self._startMerger()
+        for f in list(self.executor_server.merger_worker.functions.keys()):
+            f = str(f)
+            if f.startswith('merger:'):
+                self.executor_server.merger_worker.unRegisterFunction(f)
+
+        self.create_branch('org/project', 'stable')
+        A = self.fake_gerrit.addFakeChange('org/project', 'stable', 'A')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.assertEqual(self.getJobFromHistory('project-test2').result,
+                         'SUCCESS')
+
+        self.delete_branch('org/project', 'stable')
+        path = os.path.join(self.executor_src_root, 'review.example.com')
+        shutil.rmtree(path)
+
+        self.executor_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        build = self.builds[0]
+
+        # Make sure there is no stable branch in the checked out git repo.
+        pname = 'review.example.com/org/project'
+        work = build.getWorkspaceRepos([pname])
+        work = work[pname]
+        heads = set([str(x) for x in work.heads])
+        self.assertEqual(heads, set(['master']))
+        self.executor_server.hold_jobs_in_build = False
+        build.release()
+        self.waitUntilSettled()
+        self.assertEqual(self.getJobFromHistory('project-test1').result,
+                         'SUCCESS')
+
     def test_parallel_changes(self):
         "Test that changes are tested in parallel and merged in series"
 
@@ -4934,7 +4972,7 @@
         return repo_messages
 
     def _test_merge(self, mode):
-        us_path = os.path.join(
+        us_path = 'file://' + os.path.join(
             self.upstream_root, 'org/project-%s' % mode)
         expected_messages = [
             'initial commit',