Merge "Report the per-job build wait time to graphite"
diff --git a/NEWS.rst b/NEWS.rst
index bd09bfe..5fef40a 100644
--- a/NEWS.rst
+++ b/NEWS.rst
@@ -33,7 +33,7 @@
   matches all jobs).
 
 * Multiple triggers are now supported (currently Gerrit and a simple
-  Timer trigger ar supported).  Your layout.yaml file will need to
+  Timer trigger are supported).  Your layout.yaml file will need to
   change to add the key "gerrit:" inside of the "triggers:" list to
   specify a Gerrit trigger (and facilitate adding other kinds of
   triggers later).  See the sample layout.yaml and Zuul section of the
diff --git a/doc/source/gating.rst b/doc/source/gating.rst
index 43a5928..fff2924 100644
--- a/doc/source/gating.rst
+++ b/doc/source/gating.rst
@@ -212,19 +212,20 @@
   }
 
 
-Cross projects dependencies
----------------------------
+Cross Project Testing
+---------------------
 
 When your projects are closely coupled together, you want to make sure
 changes entering the gate are going to be tested with the version of
 other projects currently enqueued in the gate (since they will
 eventually be merged and might introduce breaking features).
 
-Such dependencies can be defined in Zuul configuration by registering a job
-in a DependentPipeline of several projects. Whenever a change enters such a
-pipeline, it will create references for the other projects as well.  As an
-example, given a main project ``acme`` and a plugin ``plugin`` you can
-define a job ``acme-tests`` which should be run for both projects:
+Such relationships can be defined in Zuul configuration by registering
+a job in a DependentPipeline of several projects. Whenever a change
+enters such a pipeline, it will create references for the other
+projects as well.  As an example, given a main project ``acme`` and a
+plugin ``plugin`` you can define a job ``acme-tests`` which should be
+run for both projects:
 
 .. code-block:: yaml
 
@@ -280,3 +281,82 @@
 When your job fetches several repositories without changes ahead in the
 queue, they may not have a Z reference in which case you can just check
 out the branch.
+
+
+Cross Repository Dependencies
+-----------------------------
+
+Zuul permits users to specify dependencies across repositories.  Using
+a special header in Git commit messages, Users may specify that a
+change depends on another change in any repository known to Zuul.
+
+Zuul's cross-repository dependencies (CRD) behave like a directed
+acyclic graph (DAG), like git itself, to indicate a one-way dependency
+relationship between changes in different git repositories.  Change A
+may depend on B, but B may not depend on A.
+
+To use them, include "Depends-On: <gerrit-change-id>" in the footer of
+a commit message.  Use the full Change-ID ('I' + 40 characters).
+
+
+Gate Pipeline
+~~~~~~~~~~~~~
+
+When Zuul sees CRD changes, it serializes them in the usual manner when
+enqueuing them into a pipeline.  This means that if change A depends on
+B, then when they are added to the gate pipeline, B will appear first
+and A will follow.  If tests for B fail, both B and A will be removed
+from the pipeline, and it will not be possible for A to merge until B
+does.
+
+Note that if changes with CRD do not share a change queue then Zuul
+is unable to enqueue them together, and the first will be required to
+merge before the second is enqueued.
+
+Check Pipeline
+~~~~~~~~~~~~~~
+
+When changes are enqueued into the check pipeline, all of the related
+dependencies (both normal git-dependencies that come from parent commits
+as well as CRD changes) appear in a dependency graph, as in gate.  This
+means that even in the check pipeline, your change will be tested with
+its dependency.  So changes that were previously unable to be fully
+tested until a related change landed in a different repo may now be
+tested together from the start.
+
+All of the changes are still independent (so you will note that the
+whole pipeline does not share a graph as in gate), but for each change
+tested, all of its dependencies are visually connected to it, and they
+are used to construct the git references that Zuul uses when testing.
+When looking at this graph on the status page, you will note that the
+dependencies show up as grey dots, while the actual change tested shows
+up as red or green.  This is to indicate that the grey changes are only
+there to establish dependencies.  Even if one of the dependencies is
+also being tested, it will show up as a grey dot when used as a
+dependency, but separately and additionally will appear as its own red
+or green dot for its test.
+
+Multiple Changes
+~~~~~~~~~~~~~~~~
+
+A Gerrit change ID may refer to multiple changes (on multiple branches
+of the same project, or even multiple projects).  In these cases, Zuul
+will treat all of the changes with that change ID as dependencies.  So
+if you say that change in project A Depends-On a change ID that has
+changes in two branches of project B, then when testing the change to
+project A, both project B changes will be applied, and when deciding
+whether the project A change can merge, both changes must merge ahead
+of it.
+
+A change may depend on more than one Gerrit change ID as well.  So it
+is possible for a change in project A to depend on a change in project
+B and a change in project C.  Simply add more "Depends-On:" lines to
+the footer.
+
+Cycles
+~~~~~~
+
+If a cycle is created by use of CRD, Zuul will abort its work very
+early.  There will be no message in Gerrit and no changes that are part
+of the cycle will be enqueued into any pipeline.  This is to protect
+Zuul from infinite loops.
diff --git a/doc/source/launchers.rst b/doc/source/launchers.rst
index b95354f..0a1e0e7 100644
--- a/doc/source/launchers.rst
+++ b/doc/source/launchers.rst
@@ -66,6 +66,11 @@
 **LOG_PATH**
   zuul also suggests a unique path for logs to the worker. This is
   "BASE_LOG_PATH/pipeline-name/job-name/uuid"
+**ZUUL_VOTING**
+  Whether Zuul considers this job voting or not.  Note that if Zuul is
+  reconfigured during the run, the voting status of a job may change
+  and this value will be out of date.  Values are '1' if voting, '0'
+  otherwise.
 
 Change related parameters
 ~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/doc/source/zuul.rst b/doc/source/zuul.rst
index 7c3e384..bcdfabb 100644
--- a/doc/source/zuul.rst
+++ b/doc/source/zuul.rst
@@ -56,6 +56,10 @@
   Whether to start the internal Gearman server (default: False).
   ``start=true``
 
+**listen_address**
+  IP address or domain name on which to listen (default: all addresses).
+  ``listen_address=127.0.0.1``
+
 **log_config**
   Path to log config file for internal Gearman server.
   ``log_config=/etc/zuul/gearman-logging.yaml``
@@ -924,9 +928,10 @@
 whether a change merges cleanly::
 
   - name: ^.*-merge$
-    failure-message: This change was unable to be automatically merged
-    with the current state of the repository. Please rebase your
-    change and upload a new patchset.
+    failure-message: This change or one of its cross-repo dependencies
+    was unable to be automatically merged with the current state of
+    its repository. Please rebase the change and upload a new
+    patchset.
 
 Projects
 """"""""
@@ -1037,7 +1042,7 @@
       - foobar-extra-special-job
 
 Individual jobs may optionally be added to pipelines (e.g. check,
-gate, et cetera) for a project, in addtion to those provided by
+gate, et cetera) for a project, in addition to those provided by
 templates.
 
 The order of the jobs listed in the project (which only affects the
diff --git a/etc/status/public_html/jquery.zuul.js b/etc/status/public_html/jquery.zuul.js
index 1db3c8e..0ca2718 100644
--- a/etc/status/public_html/jquery.zuul.js
+++ b/etc/status/public_html/jquery.zuul.js
@@ -289,10 +289,10 @@
                 }
 
                 var $change_progress_row_left = $('<div />')
-                    .addClass('col-xs-3')
+                    .addClass('col-xs-4')
                     .append($change_link);
                 var $change_progress_row_right = $('<div />')
-                    .addClass('col-xs-9')
+                    .addClass('col-xs-8')
                     .append(this.change_total_progress_bar(change));
 
                 var $change_progress_row = $('<div />')
diff --git a/etc/status/public_html/styles/zuul.css b/etc/status/public_html/styles/zuul.css
index e833f4b..44fd737 100644
--- a/etc/status/public_html/styles/zuul.css
+++ b/etc/status/public_html/styles/zuul.css
@@ -16,7 +16,9 @@
 .zuul-change-total-result {
     height: 10px;
     width: 100px;
-    margin: 5px 0 0 0;
+    margin: 0;
+    display: inline-block;
+    vertical-align: middle;
 }
 
 .zuul-spinner,
diff --git a/test-requirements.txt b/test-requirements.txt
index c68b2db..4ae3eb3 100644
--- a/test-requirements.txt
+++ b/test-requirements.txt
@@ -2,6 +2,11 @@
 
 coverage>=3.6
 sphinx>=1.1.2,!=1.2.0,!=1.3b1,<1.3
+# NOTE(tonyb) Pillow isn't directly needed but it's pulled in via
+# Collecting Pillow (from blockdiag>=1.5.0->sphinxcontrib-blockdiag>=0.5.5
+# So cap as per global-requirements until https://launchpad.net/bugs/1501995
+# is properly fixed
+Pillow>=2.4.0,<3.0.0 # MIT
 sphinxcontrib-blockdiag>=0.5.5
 discover
 fixtures>=0.3.14
diff --git a/tests/base.py b/tests/base.py
index fd27a89..abbdb0a 100755
--- a/tests/base.py
+++ b/tests/base.py
@@ -47,8 +47,9 @@
 import zuul.rpclistener
 import zuul.launcher.gearman
 import zuul.lib.swift
-import zuul.merger.server
 import zuul.merger.client
+import zuul.merger.merger
+import zuul.merger.server
 import zuul.reporter.gerrit
 import zuul.reporter.smtp
 import zuul.trigger.gerrit
@@ -145,7 +146,7 @@
                                                         self.latest_patchset),
                                      'refs/tags/init')
         repo.head.reference = ref
-        repo.head.reset(index=True, working_tree=True)
+        zuul.merger.merger.reset_repo_to_head(repo)
         repo.git.clean('-x', '-f', '-d')
 
         path = os.path.join(self.upstream_root, self.project)
@@ -167,7 +168,7 @@
 
         r = repo.index.commit(msg)
         repo.head.reference = 'master'
-        repo.head.reset(index=True, working_tree=True)
+        zuul.merger.merger.reset_repo_to_head(repo)
         repo.git.clean('-x', '-f', '-d')
         repo.heads['master'].checkout()
         return r
@@ -258,8 +259,8 @@
                  "comment": "This is a comment"}
         return event
 
-    def addApproval(self, category, value, username='jenkins',
-                    granted_on=None):
+    def addApproval(self, category, value, username='reviewer_john',
+                    granted_on=None, message=''):
         if not granted_on:
             granted_on = time.time()
         approval = {
@@ -277,20 +278,20 @@
                 del self.patchsets[-1]['approvals'][i]
         self.patchsets[-1]['approvals'].append(approval)
         event = {'approvals': [approval],
-                 'author': {'email': 'user@example.com',
-                            'name': 'User Name',
-                            'username': 'username'},
+                 'author': {'email': 'author@example.com',
+                            'name': 'Patchset Author',
+                            'username': 'author_phil'},
                  'change': {'branch': self.branch,
                             'id': 'Iaa69c46accf97d0598111724a38250ae76a22c87',
                             'number': str(self.number),
-                            'owner': {'email': 'user@example.com',
-                                      'name': 'User Name',
-                                      'username': 'username'},
+                            'owner': {'email': 'owner@example.com',
+                                      'name': 'Change Owner',
+                                      'username': 'owner_jane'},
                             'project': self.project,
                             'subject': self.subject,
                             'topic': 'master',
                             'url': 'https://hostname/459'},
-                 'comment': '',
+                 'comment': message,
                  'patchSet': self.patchsets[-1],
                  'type': 'comment-added'}
         self.data['submitRecords'] = self.getSubmitRecords()
@@ -380,11 +381,16 @@
 class FakeGerrit(object):
     log = logging.getLogger("zuul.test.FakeGerrit")
 
-    def __init__(self, *args, **kw):
+    def __init__(self, hostname, username, port=29418, keyfile=None,
+                 changes_dbs={}):
+        self.hostname = hostname
+        self.username = username
+        self.port = port
+        self.keyfile = keyfile
         self.event_queue = Queue.Queue()
         self.fixture_dir = os.path.join(FIXTURE_DIR, 'gerrit')
         self.change_number = 0
-        self.changes = {}
+        self.changes = changes_dbs.get(hostname, {})
         self.queries = []
 
     def addFakeChange(self, project, branch, subject, status='NEW'):
@@ -407,7 +413,27 @@
     def review(self, project, changeid, message, action):
         number, ps = changeid.split(',')
         change = self.changes[int(number)]
+
+        # Add the approval back onto the change (ie simulate what gerrit would
+        # do).
+        # Usually when zuul leaves a review it'll create a feedback loop where
+        # zuul's review enters another gerrit event (which is then picked up by
+        # zuul). However, we can't mimic this behaviour (by adding this
+        # approval event into the queue) as it stops jobs from checking what
+        # happens before this event is triggered. If a job needs to see what
+        # happens they can add their own verified event into the queue.
+        # Nevertheless, we can update change with the new review in gerrit.
+
+        for cat in ['CRVW', 'VRFY', 'APRV']:
+            if cat in action:
+                change.addApproval(cat, action[cat], username=self.username)
+
+        if 'label' in action:
+            parts = action['label'].split('=')
+            change.addApproval(parts[0], parts[2], username=self.username)
+
         change.messages.append(message)
+
         if 'submit' in action:
             change.setMerged()
         if message:
@@ -941,7 +967,19 @@
             args = [self.smtp_messages] + list(args)
             return FakeSMTP(*args, **kw)
 
-        zuul.lib.gerrit.Gerrit = FakeGerrit
+        # Set a changes database so multiple FakeGerrit's can report back to
+        # a virtual canonical database given by the configured hostname
+        self.gerrit_changes_dbs = {
+            self.config.get('gerrit', 'server'): {}
+        }
+
+        def FakeGerritFactory(*args, **kw):
+            kw['changes_dbs'] = self.gerrit_changes_dbs
+            return FakeGerrit(*args, **kw)
+
+        self.useFixture(fixtures.MonkeyPatch('zuul.lib.gerrit.Gerrit',
+                                             FakeGerritFactory))
+
         self.useFixture(fixtures.MonkeyPatch('smtplib.SMTP', FakeSMTPFactory))
 
         self.gerrit = FakeGerritTrigger(
@@ -1047,7 +1085,7 @@
         repo.create_tag('init')
 
         repo.head.reference = master
-        repo.head.reset(index=True, working_tree=True)
+        zuul.merger.merger.reset_repo_to_head(repo)
         repo.git.clean('-x', '-f', '-d')
 
         self.create_branch(project, 'mp')
@@ -1066,7 +1104,7 @@
         repo.index.commit('%s commit' % branch)
 
         repo.head.reference = repo.heads['master']
-        repo.head.reset(index=True, working_tree=True)
+        zuul.merger.merger.reset_repo_to_head(repo)
         repo.git.clean('-x', '-f', '-d')
 
     def ref_has_change(self, ref, change):
diff --git a/tests/fixtures/layout-live-reconfiguration-add-job.yaml b/tests/fixtures/layout-live-reconfiguration-add-job.yaml
new file mode 100644
index 0000000..e4aea6f
--- /dev/null
+++ b/tests/fixtures/layout-live-reconfiguration-add-job.yaml
@@ -0,0 +1,38 @@
+pipelines:
+  - name: gate
+    manager: DependentPipelineManager
+    failure-message: Build failed.  For information on how to proceed, see http://wiki.example.org/Test_Failures
+    trigger:
+      gerrit:
+        - event: comment-added
+          approval:
+            - approved: 1
+    success:
+      gerrit:
+        verified: 2
+        submit: true
+    failure:
+      gerrit:
+        verified: -2
+    start:
+      gerrit:
+        verified: 0
+    precedence: high
+
+jobs:
+  - name: ^.*-merge$
+    failure-message: Unable to merge change
+    hold-following-changes: true
+  - name: project-testfile
+    files:
+      - '.*-requires'
+
+projects:
+  - name: org/project
+    merge-mode: cherry-pick
+    gate:
+      - project-merge:
+        - project-test1
+        - project-test2
+        - project-test3
+        - project-testfile
diff --git a/tests/fixtures/layout-live-reconfiguration-failed-job.yaml b/tests/fixtures/layout-live-reconfiguration-failed-job.yaml
new file mode 100644
index 0000000..e811af1
--- /dev/null
+++ b/tests/fixtures/layout-live-reconfiguration-failed-job.yaml
@@ -0,0 +1,25 @@
+pipelines:
+  - name: check
+    manager: IndependentPipelineManager
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        verified: 1
+    failure:
+      gerrit:
+        verified: -1
+
+jobs:
+  - name: ^.*-merge$
+    failure-message: Unable to merge change
+    hold-following-changes: true
+
+projects:
+  - name: org/project
+    merge-mode: cherry-pick
+    check:
+      - project-merge:
+        - project-test2
+        - project-testfile
diff --git a/tests/fixtures/layout-live-reconfiguration-shared-queue.yaml b/tests/fixtures/layout-live-reconfiguration-shared-queue.yaml
new file mode 100644
index 0000000..ad3f666
--- /dev/null
+++ b/tests/fixtures/layout-live-reconfiguration-shared-queue.yaml
@@ -0,0 +1,62 @@
+pipelines:
+  - name: check
+    manager: IndependentPipelineManager
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        verified: 1
+    failure:
+      gerrit:
+        verified: -1
+
+  - name: gate
+    manager: DependentPipelineManager
+    failure-message: Build failed.  For information on how to proceed, see http://wiki.example.org/Test_Failures
+    trigger:
+      gerrit:
+        - event: comment-added
+          approval:
+            - approved: 1
+    success:
+      gerrit:
+        verified: 2
+        submit: true
+    failure:
+      gerrit:
+        verified: -2
+    start:
+      gerrit:
+        verified: 0
+    precedence: high
+
+jobs:
+  - name: ^.*-merge$
+    failure-message: Unable to merge change
+    hold-following-changes: true
+  - name: project1-project2-integration
+    queue-name: integration
+
+projects:
+  - name: org/project1
+    check:
+      - project1-merge:
+        - project1-test1
+        - project1-test2
+    gate:
+      - project1-merge:
+        - project1-test1
+        - project1-test2
+
+  - name: org/project2
+    check:
+      - project2-merge:
+        - project2-test1
+        - project2-test2
+        - project1-project2-integration
+    gate:
+      - project2-merge:
+        - project2-test1
+        - project2-test2
+        - project1-project2-integration
diff --git a/tests/test_requirements.py b/tests/test_requirements.py
index 120e37e..4316925 100644
--- a/tests/test_requirements.py
+++ b/tests/test_requirements.py
@@ -52,13 +52,14 @@
         self.assertEqual(len(self.history), 0)
 
         # Add a too-old +1, should not be enqueued
-        A.addApproval('VRFY', 1, granted_on=time.time() - 72 * 60 * 60)
+        A.addApproval('VRFY', 1, username='jenkins',
+                      granted_on=time.time() - 72 * 60 * 60)
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 0)
 
         # Add a recent +1
-        self.fake_gerrit.addEvent(A.addApproval('VRFY', 1))
+        self.fake_gerrit.addEvent(A.addApproval('VRFY', 1, username='jenkins'))
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
@@ -95,7 +96,8 @@
         self.assertEqual(len(self.history), 0)
 
         # Add an old +1 which should be enqueued
-        A.addApproval('VRFY', 1, granted_on=time.time() - 72 * 60 * 60)
+        A.addApproval('VRFY', 1, username='jenkins',
+                      granted_on=time.time() - 72 * 60 * 60)
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
@@ -126,7 +128,7 @@
         self.assertEqual(len(self.history), 0)
 
         # Add an approval from Jenkins
-        A.addApproval('VRFY', 1)
+        A.addApproval('VRFY', 1, username='jenkins')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
@@ -157,7 +159,7 @@
         self.assertEqual(len(self.history), 0)
 
         # Add an approval from Jenkins
-        A.addApproval('VRFY', 1)
+        A.addApproval('VRFY', 1, username='jenkins')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
@@ -188,13 +190,13 @@
         self.assertEqual(len(self.history), 0)
 
         # A -1 from jenkins should not cause it to be enqueued
-        A.addApproval('VRFY', -1)
+        A.addApproval('VRFY', -1, username='jenkins')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 0)
 
         # A +1 should allow it to be enqueued
-        A.addApproval('VRFY', 1)
+        A.addApproval('VRFY', 1, username='jenkins')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
@@ -225,19 +227,19 @@
         self.assertEqual(len(self.history), 0)
 
         # A -1 from jenkins should not cause it to be enqueued
-        A.addApproval('VRFY', -1)
+        A.addApproval('VRFY', -1, username='jenkins')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 0)
 
         # A -2 from jenkins should not cause it to be enqueued
-        A.addApproval('VRFY', -2)
+        A.addApproval('VRFY', -2, username='jenkins')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 0)
 
-        # A +1 should allow it to be enqueued
-        A.addApproval('VRFY', 1)
+        # A +1 from jenkins should allow it to be enqueued
+        A.addApproval('VRFY', 1, username='jenkins')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
@@ -251,7 +253,7 @@
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
 
-        B.addApproval('VRFY', 2)
+        B.addApproval('VRFY', 2, username='jenkins')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 2)
diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py
index 032a8f8..4955de6 100755
--- a/tests/test_scheduler.py
+++ b/tests/test_scheduler.py
@@ -111,6 +111,9 @@
         self.assertReportedStat(
             'zuul.pipeline.gate.org.project.total_changes', value='1|c')
 
+        for build in self.builds:
+            self.assertEqual(build.parameters['ZUUL_VOTING'], '1')
+
     def test_initial_pipeline_gauges(self):
         "Test that each pipeline reported its length on start"
         pipeline_names = self.sched.layout.pipelines.keys()
@@ -1256,6 +1259,9 @@
             self.getJobFromHistory('nonvoting-project-test2').result,
             'FAILURE')
 
+        for build in self.builds:
+            self.assertEqual(build.parameters['ZUUL_VOTING'], '0')
+
     def test_check_queue_success(self):
         "Test successful check queue jobs."
 
@@ -2036,6 +2042,30 @@
         self.assertEqual(self.history[0].name, 'gate-noop')
         self.assertEqual(self.history[0].result, 'SUCCESS')
 
+    def test_file_head(self):
+        # This is a regression test for an observed bug.  A change
+        # with a file named "HEAD" in the root directory of the repo
+        # was processed by a merger.  It then was unable to reset the
+        # repo because of:
+        #   GitCommandError: 'git reset --hard HEAD' returned
+        #       with exit code 128
+        #   stderr: 'fatal: ambiguous argument 'HEAD': both revision
+        #       and filename
+        #   Use '--' to separate filenames from revisions'
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addPatchset(['HEAD'])
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(2))
+        self.waitUntilSettled()
+
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertIn('Build succeeded', A.messages[0])
+        self.assertIn('Build succeeded', B.messages[0])
+
     def test_file_jobs(self):
         "Test that file jobs run only when appropriate"
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
@@ -2241,6 +2271,235 @@
         self.assertEqual(A.data['status'], 'MERGED')
         self.assertEqual(A.reported, 2)
 
+    def test_live_reconfiguration_merge_conflict(self):
+        # A real-world bug: a change in a gate queue has a merge
+        # conflict and a job is added to its project while it's
+        # sitting in the queue.  The job gets added to the change and
+        # enqueued and the change gets stuck.
+        self.worker.registerFunction('build:project-test3')
+        self.worker.hold_jobs_in_build = True
+
+        # This change is fine.  It's here to stop the queue long
+        # enough for the next change to be subject to the
+        # reconfiguration, as well as to provide a conflict for the
+        # next change.  This change will succeed and merge.
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addPatchset(['conflict'])
+        A.addApproval('CRVW', 2)
+
+        # This change will be in merge conflict.  During the
+        # reconfiguration, we will add a job.  We want to make sure
+        # that doesn't cause it to get stuck.
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        B.addPatchset(['conflict'])
+        B.addApproval('CRVW', 2)
+
+        self.fake_gerrit.addEvent(A.addApproval('APRV', 1))
+        self.fake_gerrit.addEvent(B.addApproval('APRV', 1))
+
+        self.waitUntilSettled()
+
+        # No jobs have run yet
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(B.reported, 1)
+        self.assertEqual(len(self.history), 0)
+
+        # Add the "project-test3" job.
+        self.config.set('zuul', 'layout_config',
+                        'tests/fixtures/layout-live-'
+                        'reconfiguration-add-job.yaml')
+        self.sched.reconfigure(self.config)
+        self.waitUntilSettled()
+
+        self.worker.hold_jobs_in_build = False
+        self.worker.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(self.getJobFromHistory('project-merge').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test1').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test2').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test3').result,
+                         'SUCCESS')
+        self.assertEqual(len(self.history), 4)
+
+    def test_live_reconfiguration_failed_root(self):
+        # An extrapolation of test_live_reconfiguration_merge_conflict
+        # that tests a job added to a job tree with a failed root does
+        # not run.
+        self.worker.registerFunction('build:project-test3')
+        self.worker.hold_jobs_in_build = True
+
+        # This change is fine.  It's here to stop the queue long
+        # enough for the next change to be subject to the
+        # reconfiguration.  This change will succeed and merge.
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.addPatchset(['conflict'])
+        A.addApproval('CRVW', 2)
+        self.fake_gerrit.addEvent(A.addApproval('APRV', 1))
+        self.waitUntilSettled()
+        self.worker.release('.*-merge')
+        self.waitUntilSettled()
+
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        self.worker.addFailTest('project-merge', B)
+        B.addApproval('CRVW', 2)
+        self.fake_gerrit.addEvent(B.addApproval('APRV', 1))
+        self.waitUntilSettled()
+
+        self.worker.release('.*-merge')
+        self.waitUntilSettled()
+
+        # Both -merge jobs have run, but no others.
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(B.reported, 1)
+        self.assertEqual(self.history[0].result, 'SUCCESS')
+        self.assertEqual(self.history[0].name, 'project-merge')
+        self.assertEqual(self.history[1].result, 'FAILURE')
+        self.assertEqual(self.history[1].name, 'project-merge')
+        self.assertEqual(len(self.history), 2)
+
+        # Add the "project-test3" job.
+        self.config.set('zuul', 'layout_config',
+                        'tests/fixtures/layout-live-'
+                        'reconfiguration-add-job.yaml')
+        self.sched.reconfigure(self.config)
+        self.waitUntilSettled()
+
+        self.worker.hold_jobs_in_build = False
+        self.worker.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(self.history[0].result, 'SUCCESS')
+        self.assertEqual(self.history[0].name, 'project-merge')
+        self.assertEqual(self.history[1].result, 'FAILURE')
+        self.assertEqual(self.history[1].name, 'project-merge')
+        self.assertEqual(self.history[2].result, 'SUCCESS')
+        self.assertEqual(self.history[3].result, 'SUCCESS')
+        self.assertEqual(self.history[4].result, 'SUCCESS')
+        self.assertEqual(len(self.history), 5)
+
+    def test_live_reconfiguration_failed_job(self):
+        # Test that a change with a removed failing job does not
+        # disrupt reconfiguration.  If a change has a failed job and
+        # that job is removed during a reconfiguration, we observed a
+        # bug where the code to re-set build statuses would run on
+        # that build and raise an exception because the job no longer
+        # existed.
+        self.worker.hold_jobs_in_build = True
+
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+
+        # This change will fail and later be removed by the reconfiguration.
+        self.worker.addFailTest('project-test1', A)
+
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.worker.release('.*-merge')
+        self.waitUntilSettled()
+        self.worker.release('project-test1')
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(A.reported, 0)
+
+        self.assertEqual(self.getJobFromHistory('project-merge').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-test1').result,
+                         'FAILURE')
+        self.assertEqual(len(self.history), 2)
+
+        # Remove the test1 job.
+        self.config.set('zuul', 'layout_config',
+                        'tests/fixtures/layout-live-'
+                        'reconfiguration-failed-job.yaml')
+        self.sched.reconfigure(self.config)
+        self.waitUntilSettled()
+
+        self.worker.hold_jobs_in_build = False
+        self.worker.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(self.getJobFromHistory('project-test2').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project-testfile').result,
+                         'SUCCESS')
+        self.assertEqual(len(self.history), 4)
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(A.reported, 1)
+        self.assertIn('Build succeeded', A.messages[0])
+        # Ensure the removed job was not included in the report.
+        self.assertNotIn('project-test1', A.messages[0])
+
+    def test_live_reconfiguration_shared_queue(self):
+        # Test that a change with a failing job which was removed from
+        # this project but otherwise still exists in the system does
+        # not disrupt reconfiguration.
+
+        self.worker.hold_jobs_in_build = True
+
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+
+        self.worker.addFailTest('project1-project2-integration', A)
+
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.worker.release('.*-merge')
+        self.waitUntilSettled()
+        self.worker.release('project1-project2-integration')
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(A.reported, 0)
+
+        self.assertEqual(self.getJobFromHistory('project1-merge').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory(
+            'project1-project2-integration').result, 'FAILURE')
+        self.assertEqual(len(self.history), 2)
+
+        # Remove the integration job.
+        self.config.set('zuul', 'layout_config',
+                        'tests/fixtures/layout-live-'
+                        'reconfiguration-shared-queue.yaml')
+        self.sched.reconfigure(self.config)
+        self.waitUntilSettled()
+
+        self.worker.hold_jobs_in_build = False
+        self.worker.release()
+        self.waitUntilSettled()
+
+        self.assertEqual(self.getJobFromHistory('project1-merge').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project1-test1').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory('project1-test2').result,
+                         'SUCCESS')
+        self.assertEqual(self.getJobFromHistory(
+            'project1-project2-integration').result, 'FAILURE')
+        self.assertEqual(len(self.history), 4)
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(A.reported, 1)
+        self.assertIn('Build succeeded', A.messages[0])
+        # Ensure the removed job was not included in the report.
+        self.assertNotIn('project1-project2-integration', A.messages[0])
+
     def test_live_reconfiguration_functions(self):
         "Test live reconfiguration with a custom function"
         self.worker.registerFunction('build:node-project-test1:debian')
@@ -2483,7 +2742,7 @@
         self.worker.release('.*')
         self.waitUntilSettled()
 
-    def test_client_enqueue(self):
+    def test_client_enqueue_change(self):
         "Test that the RPC client can enqueue a change"
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         A.addApproval('CRVW', 2)
@@ -2506,6 +2765,24 @@
         self.assertEqual(A.reported, 2)
         self.assertEqual(r, True)
 
+    def test_client_enqueue_ref(self):
+        "Test that the RPC client can enqueue a ref"
+
+        client = zuul.rpcclient.RPCClient('127.0.0.1',
+                                          self.gearman_server.port)
+        r = client.enqueue_ref(
+            pipeline='post',
+            project='org/project',
+            trigger='gerrit',
+            ref='master',
+            oldrev='90f173846e3af9154517b88543ffbd1691f31366',
+            newrev='d479a0bfcb34da57a31adb2a595c0cf687812543')
+        self.waitUntilSettled()
+        job_names = [x.name for x in self.history]
+        self.assertEqual(len(self.history), 1)
+        self.assertIn('project-post', job_names)
+        self.assertEqual(r, True)
+
     def test_client_enqueue_negative(self):
         "Test that the RPC client returns errors"
         client = zuul.rpcclient.RPCClient('127.0.0.1',
@@ -2968,9 +3245,10 @@
         self.registerJobs()
 
         self.assertEqual(
-            "Merge Failed.\n\nThis change was unable to be automatically "
-            "merged with the current state of the repository. Please rebase "
-            "your change and upload a new patchset.",
+            "Merge Failed.\n\nThis change or one of its cross-repo "
+            "dependencies was unable to be automatically merged with the "
+            "current state of its repository. Please rebase the change and "
+            "upload a new patchset.",
             self.sched.layout.pipelines['check'].merge_failure_message)
         self.assertEqual(
             "The merge failed! For more information...",
diff --git a/tests/test_zuultrigger.py b/tests/test_zuultrigger.py
index 2f0e4f0..0d52fc9 100644
--- a/tests/test_zuultrigger.py
+++ b/tests/test_zuultrigger.py
@@ -107,9 +107,10 @@
         self.assertEqual(E.reported, 0)
         self.assertEqual(
             B.messages[0],
-            "Merge Failed.\n\nThis change was unable to be automatically "
-            "merged with the current state of the repository. Please rebase "
-            "your change and upload a new patchset.")
+            "Merge Failed.\n\nThis change or one of its cross-repo "
+            "dependencies was unable to be automatically merged with the "
+            "current state of its repository. Please rebase the change and "
+            "upload a new patchset.")
 
         self.assertTrue("project:org/project status:open" in
                         self.fake_gerrit.queries)
@@ -133,8 +134,9 @@
         self.assertEqual(E.reported, 1)
         self.assertEqual(
             E.messages[0],
-            "Merge Failed.\n\nThis change was unable to be automatically "
-            "merged with the current state of the repository. Please rebase "
-            "your change and upload a new patchset.")
+            "Merge Failed.\n\nThis change or one of its cross-repo "
+            "dependencies was unable to be automatically merged with the "
+            "current state of its repository. Please rebase the change and "
+            "upload a new patchset.")
         self.assertEqual(self.fake_gerrit.queries[1],
                          "project:org/project status:open")
diff --git a/zuul/cmd/client.py b/zuul/cmd/client.py
index 10f2854..59ac419 100644
--- a/zuul/cmd/client.py
+++ b/zuul/cmd/client.py
@@ -56,6 +56,24 @@
                                  required=True)
         cmd_enqueue.set_defaults(func=self.enqueue)
 
+        cmd_enqueue = subparsers.add_parser('enqueue-ref',
+                                            help='enqueue a ref')
+        cmd_enqueue.add_argument('--trigger', help='trigger name',
+                                 required=True)
+        cmd_enqueue.add_argument('--pipeline', help='pipeline name',
+                                 required=True)
+        cmd_enqueue.add_argument('--project', help='project name',
+                                 required=True)
+        cmd_enqueue.add_argument('--ref', help='ref name',
+                                 required=True)
+        cmd_enqueue.add_argument(
+            '--oldrev', help='old revision',
+            default='0000000000000000000000000000000000000000')
+        cmd_enqueue.add_argument(
+            '--newrev', help='new revision',
+            default='0000000000000000000000000000000000000000')
+        cmd_enqueue.set_defaults(func=self.enqueue_ref)
+
         cmd_promote = subparsers.add_parser('promote',
                                             help='promote one or more changes')
         cmd_promote.add_argument('--pipeline', help='pipeline name',
@@ -82,6 +100,9 @@
         show_running_jobs.set_defaults(func=self.show_running_jobs)
 
         self.args = parser.parse_args()
+        if self.args.func == self.enqueue_ref:
+            if self.args.oldrev == self.args.newrev:
+                parser.error("The old and new revisions must not be the same.")
 
     def setup_logging(self):
         """Client logging does not rely on conf file"""
@@ -112,6 +133,16 @@
                            change=self.args.change)
         return r
 
+    def enqueue_ref(self):
+        client = zuul.rpcclient.RPCClient(self.server, self.port)
+        r = client.enqueue_ref(pipeline=self.args.pipeline,
+                               project=self.args.project,
+                               trigger=self.args.trigger,
+                               ref=self.args.ref,
+                               oldrev=self.args.oldrev,
+                               newrev=self.args.newrev)
+        return r
+
     def promote(self):
         client = zuul.rpcclient.RPCClient(self.server, self.port)
         r = client.promote(pipeline=self.args.pipeline,
diff --git a/zuul/cmd/cloner.py b/zuul/cmd/cloner.py
index 554e3f9..e4a0e7b 100755
--- a/zuul/cmd/cloner.py
+++ b/zuul/cmd/cloner.py
@@ -88,17 +88,14 @@
             )
 
         args = parser.parse_args()
+        # Validate ZUUL_* arguments. If ref is provided then URL is required.
+        zuul_args = [zuul_opt for zuul_opt, val in vars(args).items()
+                     if zuul_opt.startswith('zuul') and val is not None]
+        if 'zuul_ref' in zuul_args and 'zuul_url' not in zuul_args:
+            parser.error("Specifying a Zuul ref requires a Zuul url. "
+                         "Define Zuul arguments either via environment "
+                         "variables or using options above.")
 
-        # Validate ZUUL_* arguments. If any ZUUL_* argument is set they
-        # must all be set, otherwise fallback to defaults.
-        zuul_missing = [zuul_opt for zuul_opt, val in vars(args).items()
-                        if zuul_opt.startswith('zuul') and val is None]
-        if (len(zuul_missing) > 0 and
-            len(zuul_missing) < len(ZUUL_ENV_SUFFIXES)):
-            parser.error(("Some Zuul parameters are not set:\n\t%s\n"
-                          "Define them either via environment variables or "
-                          "using options above." %
-                          "\n\t".join(sorted(zuul_missing))))
         self.args = args
 
     def setup_logging(self, color=False, verbose=False):
diff --git a/zuul/cmd/server.py b/zuul/cmd/server.py
index 832eae4..2d99a1f 100755
--- a/zuul/cmd/server.py
+++ b/zuul/cmd/server.py
@@ -62,7 +62,10 @@
         signal.signal(signal.SIGHUP, signal.SIG_IGN)
         self.read_config()
         self.setup_logging('zuul', 'log_config')
-        self.sched.reconfigure(self.config)
+        try:
+            self.sched.reconfigure(self.config)
+        except Exception:
+            self.log.exception("Reconfiguration failed:")
         signal.signal(signal.SIGHUP, self.reconfigure_handler)
 
     def exit_handler(self, signum, frame):
@@ -118,7 +121,12 @@
             import gear
             statsd_host = os.environ.get('STATSD_HOST')
             statsd_port = int(os.environ.get('STATSD_PORT', 8125))
+            if self.config.has_option('gearman_server', 'listen_address'):
+                host = self.config.get('gearman_server', 'listen_address')
+            else:
+                host = None
             gear.Server(4730,
+                        host=host,
                         statsd_host=statsd_host,
                         statsd_port=statsd_port,
                         statsd_prefix='zuul.geard')
diff --git a/zuul/launcher/gearman.py b/zuul/launcher/gearman.py
index 6c8b689..e43e867 100644
--- a/zuul/launcher/gearman.py
+++ b/zuul/launcher/gearman.py
@@ -277,6 +277,7 @@
                       ZUUL_PROJECT=item.change.project.name)
         params['ZUUL_PIPELINE'] = pipeline.name
         params['ZUUL_URL'] = item.current_build_set.zuul_url
+        params['ZUUL_VOTING'] = job.voting and '1' or '0'
         if hasattr(item.change, 'refspec'):
             changes_str = '^'.join(
                 ['%s:%s:%s' % (i.change.project.name, i.change.branch,
@@ -342,8 +343,7 @@
         build.parameters = params
 
         if job.name == 'noop':
-            build.result = 'SUCCESS'
-            self.sched.onBuildCompleted(build)
+            self.sched.onBuildCompleted(build, 'SUCCESS')
             return build
 
         gearman_job = gear.Job(name, json.dumps(params),
@@ -433,8 +433,7 @@
                     build.retry = True
                 self.log.info("Build %s complete, result %s" %
                               (job, result))
-                build.result = result
-                self.sched.onBuildCompleted(build)
+                self.sched.onBuildCompleted(build, result)
             # The test suite expects the build to be removed from the
             # internal dict after it's added to the report queue.
             del self.builds[job.unique]
diff --git a/zuul/lib/cloner.py b/zuul/lib/cloner.py
index d697648..0ac7f0f 100644
--- a/zuul/lib/cloner.py
+++ b/zuul/lib/cloner.py
@@ -138,8 +138,11 @@
         if project in self.project_branches:
             indicated_branch = self.project_branches[project]
 
-        override_zuul_ref = re.sub(self.zuul_branch, indicated_branch,
-                                   self.zuul_ref)
+        if indicated_branch:
+            override_zuul_ref = re.sub(self.zuul_branch, indicated_branch,
+                                       self.zuul_ref)
+        else:
+            override_zuul_ref = None
 
         if indicated_branch and repo.hasBranch(indicated_branch):
             self.log.info("upstream repo has branch %s", indicated_branch)
@@ -150,14 +153,18 @@
             # FIXME should be origin HEAD branch which might not be 'master'
             fallback_branch = 'master'
 
-        fallback_zuul_ref = re.sub(self.zuul_branch, fallback_branch,
-                                   self.zuul_ref)
+        if self.zuul_branch:
+            fallback_zuul_ref = re.sub(self.zuul_branch, fallback_branch,
+                                       self.zuul_ref)
+        else:
+            fallback_zuul_ref = None
 
         # If we have a non empty zuul_ref to use, use it. Otherwise we fall
         # back to checking out the branch.
         if ((override_zuul_ref and
             self.fetchFromZuul(repo, project, override_zuul_ref)) or
-            (fallback_zuul_ref != override_zuul_ref and
+            (fallback_zuul_ref and
+             fallback_zuul_ref != override_zuul_ref and
             self.fetchFromZuul(repo, project, fallback_zuul_ref))):
             # Work around a bug in GitPython which can not parse FETCH_HEAD
             gitcmd = git.Git(dest)
@@ -169,9 +176,9 @@
             # Checkout branch
             self.log.info("Falling back to branch %s", fallback_branch)
             try:
-                repo.checkout('remotes/origin/%s' % fallback_branch)
+                commit = repo.checkout('remotes/origin/%s' % fallback_branch)
             except (ValueError, GitCommandError):
                 self.log.exception("Fallback branch not found: %s",
                                    fallback_branch)
-            self.log.info("Prepared %s repo with branch %s",
-                          project, fallback_branch)
+            self.log.info("Prepared %s repo with branch %s at commit %s",
+                          project, fallback_branch, commit)
diff --git a/zuul/lib/gerrit.py b/zuul/lib/gerrit.py
index 6c7906b..90faf40 100644
--- a/zuul/lib/gerrit.py
+++ b/zuul/lib/gerrit.py
@@ -146,7 +146,7 @@
 
     def simpleQuery(self, query):
         def _query_chunk(query):
-            args = '--current-patch-set'
+            args = '--commit-message --current-patch-set'
 
             cmd = 'gerrit query --format json %s %s' % (
                 args, query)
diff --git a/zuul/merger/merger.py b/zuul/merger/merger.py
index f36b974..1e881bf 100644
--- a/zuul/merger/merger.py
+++ b/zuul/merger/merger.py
@@ -20,6 +20,21 @@
 import zuul.model
 
 
+def reset_repo_to_head(repo):
+    # This lets us reset the repo even if there is a file in the root
+    # directory named 'HEAD'.  Currently, GitPython does not allow us
+    # to instruct it to always include the '--' to disambiguate.  This
+    # should no longer be necessary if this PR merges:
+    #   https://github.com/gitpython-developers/GitPython/pull/319
+    try:
+        repo.git.reset('--hard', 'HEAD', '--')
+    except git.GitCommandError as e:
+        # git nowadays may use 1 as status to indicate there are still unstaged
+        # modifications after the reset
+        if e.status != 1:
+            raise
+
+
 class ZuulReference(git.Reference):
     _common_path_default = "refs/zuul"
     _points_to_commits_only = True
@@ -71,9 +86,9 @@
         return repo
 
     def reset(self):
-        repo = self.createRepoObject()
         self.log.debug("Resetting repository %s" % self.local_path)
         self.update()
+        repo = self.createRepoObject()
         origin = repo.remotes.origin
         for ref in origin.refs:
             if ref.remote_head == 'HEAD':
@@ -82,7 +97,7 @@
 
         # Reset to remote HEAD (usually origin/master)
         repo.head.reference = origin.refs['HEAD']
-        repo.head.reset(index=True, working_tree=True)
+        reset_repo_to_head(repo)
         repo.git.clean('-x', '-f', '-d')
 
     def prune(self):
@@ -114,7 +129,8 @@
         repo = self.createRepoObject()
         self.log.debug("Checking out %s" % ref)
         repo.head.reference = ref
-        repo.head.reset(index=True, working_tree=True)
+        reset_repo_to_head(repo)
+        return repo.head.commit
 
     def cherryPick(self, ref):
         repo = self.createRepoObject()
@@ -152,7 +168,7 @@
 
     def createZuulRef(self, ref, commit='HEAD'):
         repo = self.createRepoObject()
-        self.log.debug("CreateZuulRef %s at %s" % (ref, commit))
+        self.log.debug("CreateZuulRef %s at %s on %s" % (ref, commit, repo))
         ref = ZuulReference.create(repo, ref, commit)
         return ref.commit
 
diff --git a/zuul/model.py b/zuul/model.py
index e852033..20d4fee 100644
--- a/zuul/model.py
+++ b/zuul/model.py
@@ -543,6 +543,9 @@
             t = JobTree(job)
             self.job_trees.append(t)
             return t
+        for tree in self.job_trees:
+            if tree.job == job:
+                return tree
 
     def getJobs(self):
         jobs = []
diff --git a/zuul/rpcclient.py b/zuul/rpcclient.py
index f43c3b9..609f636 100644
--- a/zuul/rpcclient.py
+++ b/zuul/rpcclient.py
@@ -56,6 +56,16 @@
                 }
         return not self.submitJob('zuul:enqueue', data).failure
 
+    def enqueue_ref(self, pipeline, project, trigger, ref, oldrev, newrev):
+        data = {'pipeline': pipeline,
+                'project': project,
+                'trigger': trigger,
+                'ref': ref,
+                'oldrev': oldrev,
+                'newrev': newrev,
+                }
+        return not self.submitJob('zuul:enqueue_ref', data).failure
+
     def promote(self, pipeline, change_ids):
         data = {'pipeline': pipeline,
                 'change_ids': change_ids,
diff --git a/zuul/rpclistener.py b/zuul/rpclistener.py
index 05b8d03..d54da9f 100644
--- a/zuul/rpclistener.py
+++ b/zuul/rpclistener.py
@@ -48,6 +48,7 @@
 
     def register(self):
         self.worker.registerFunction("zuul:enqueue")
+        self.worker.registerFunction("zuul:enqueue_ref")
         self.worker.registerFunction("zuul:promote")
         self.worker.registerFunction("zuul:get_running_jobs")
 
@@ -83,7 +84,7 @@
             except Exception:
                 self.log.exception("Exception while getting job")
 
-    def handle_enqueue(self, job):
+    def _common_enqueue(self, job):
         args = json.loads(job.arguments)
         event = model.TriggerEvent()
         errors = ''
@@ -106,6 +107,11 @@
         else:
             errors += 'Invalid pipeline: %s\n' % (args['pipeline'],)
 
+        return (args, event, errors, pipeline, project)
+
+    def handle_enqueue(self, job):
+        (args, event, errors, pipeline, project) = self._common_enqueue(job)
+
         if not errors:
             event.change_number, event.patch_number = args['change'].split(',')
             try:
@@ -119,6 +125,20 @@
             self.sched.enqueue(event)
             job.sendWorkComplete()
 
+    def handle_enqueue_ref(self, job):
+        (args, event, errors, pipeline, project) = self._common_enqueue(job)
+
+        if not errors:
+            event.ref = args['ref']
+            event.oldrev = args['oldrev']
+            event.newrev = args['newrev']
+
+        if errors:
+            job.sendWorkException(errors.encode('utf8'))
+        else:
+            self.sched.enqueue(event)
+            job.sendWorkComplete()
+
     def handle_promote(self, job):
         args = json.loads(job.arguments)
         pipeline_name = args['pipeline']
diff --git a/zuul/scheduler.py b/zuul/scheduler.py
index f3ecc03..e9e6cc7 100644
--- a/zuul/scheduler.py
+++ b/zuul/scheduler.py
@@ -272,9 +272,10 @@
             pipeline.failure_message = conf_pipeline.get('failure-message',
                                                          "Build failed.")
             pipeline.merge_failure_message = conf_pipeline.get(
-                'merge-failure-message', "Merge Failed.\n\nThis change was "
-                "unable to be automatically merged with the current state of "
-                "the repository. Please rebase your change and upload a new "
+                'merge-failure-message', "Merge Failed.\n\nThis change or one "
+                "of its cross-repo dependencies was unable to be "
+                "automatically merged with the current state of its "
+                "repository. Please rebase the change and upload a new "
                 "patchset.")
             pipeline.success_message = conf_pipeline.get('success-message',
                                                          "Build succeeded.")
@@ -471,10 +472,6 @@
                         config_project.update(
                             {pipeline.name: expanded[pipeline.name] +
                              config_project.get(pipeline.name, [])})
-            # TODO: future enhancement -- handle the case where
-            # duplicate jobs have different children and you want all
-            # of the children to run after a single run of the
-            # parent).
 
             layout.projects[config_project['name']] = project
             mode = config_project.get('merge-mode', 'merge-resolve')
@@ -548,9 +545,15 @@
         self.wake_event.set()
         self.log.debug("Done adding start event for build: %s" % build)
 
-    def onBuildCompleted(self, build):
-        self.log.debug("Adding complete event for build: %s" % build)
+    def onBuildCompleted(self, build, result):
+        self.log.debug("Adding complete event for build: %s result: %s" % (
+            build, result))
         build.end_time = time.time()
+        # Note, as soon as the result is set, other threads may act
+        # upon this, even though the event hasn't been fully
+        # processed.  Ensure that any other data from the event (eg,
+        # timing) is recorded before setting the result.
+        build.result = result
         try:
             if statsd and build.pipeline:
                 jobname = build.job.name.replace('.', '_')
@@ -692,7 +695,7 @@
                     continue
                 self.log.debug("Re-enqueueing changes for pipeline %s" % name)
                 items_to_remove = []
-                builds_to_remove = []
+                builds_to_cancel = []
                 last_head = None
                 for shared_queue in old_pipeline.queues:
                     for item in shared_queue.queue:
@@ -711,19 +714,21 @@
                             items_to_remove.append(item)
                             continue
                         item.change.project = project
+                        item_jobs = new_pipeline.getJobs(item)
                         for build in item.current_build_set.getBuilds():
                             job = layout.jobs.get(build.job.name)
-                            if job:
+                            if job and job in item_jobs:
                                 build.job = job
                             else:
-                                builds_to_remove.append(build)
+                                item.removeBuild(build)
+                                builds_to_cancel.append(build)
                         if not new_pipeline.manager.reEnqueueItem(item,
                                                                   last_head):
                             items_to_remove.append(item)
                 for item in items_to_remove:
                     for build in item.current_build_set.getBuilds():
-                        builds_to_remove.append(build)
-                for build in builds_to_remove:
+                        builds_to_cancel.append(build)
+                for build in builds_to_cancel:
                     self.log.warning(
                         "Canceling build %s during reconfiguration" % (build,))
                     try:
@@ -1196,6 +1201,18 @@
                 self.log.debug("Re-enqueing change %s in queue %s" %
                                (item.change, change_queue))
                 change_queue.enqueueItem(item)
+
+                # Re-set build results in case any new jobs have been
+                # added to the tree.
+                for build in item.current_build_set.getBuilds():
+                    if build.result:
+                        self.pipeline.setResult(item, build)
+                # Similarly, reset the item state.
+                if item.current_build_set.unable_to_merge:
+                    self.pipeline.setUnableToMerge(item)
+                if item.dequeued_needing_change:
+                    self.pipeline.setDequeuedNeedingChange(item)
+
                 self.reportStats(item)
                 return True
             else:
@@ -1363,6 +1380,7 @@
                                    "for change %s" % (build, item.change))
             build.result = 'CANCELED'
             canceled = True
+        self.updateBuildDescriptions(old_build_set)
         for item_behind in item.items_behind:
             self.log.debug("Canceling jobs for change %s, behind change %s" %
                            (item_behind.change, item.change))
@@ -1370,7 +1388,7 @@
                 canceled = True
         return canceled
 
-    def _processOneItem(self, item, nnfi, ready_ahead):
+    def _processOneItem(self, item, nnfi):
         changed = False
         item_ahead = item.item_ahead
         if item_ahead and (not item_ahead.live):
@@ -1390,7 +1408,7 @@
                     self.reportItem(item)
                 except MergeFailure:
                     pass
-            return (True, nnfi, ready_ahead)
+            return (True, nnfi)
         dep_items = self.getFailingDependentItems(item)
         actionable = change_queue.isActionable(item)
         item.active = actionable
@@ -1417,9 +1435,7 @@
                 if item.current_build_set.unable_to_merge:
                     failing_reasons.append("it has a merge conflict")
                     ready = False
-        if not ready:
-            ready_ahead = False
-        if actionable and ready_ahead and self.launchJobs(item):
+        if actionable and ready and self.launchJobs(item):
             changed = True
         if self.pipeline.didAnyJobFail(item):
             failing_reasons.append("at least one job failed")
@@ -1446,7 +1462,7 @@
         if failing_reasons:
             self.log.debug("%s is a failing item because %s" %
                            (item, failing_reasons))
-        return (changed, nnfi, ready_ahead)
+        return (changed, nnfi)
 
     def processQueue(self):
         # Do whatever needs to be done for each change in the queue
@@ -1455,10 +1471,9 @@
         for queue in self.pipeline.queues:
             queue_changed = False
             nnfi = None  # Nearest non-failing item
-            ready_ahead = True  # All build sets ahead are ready
             for item in queue.queue[:]:
-                item_changed, nnfi, ready_ahhead = self._processOneItem(
-                    item, nnfi, ready_ahead)
+                item_changed, nnfi = self._processOneItem(
+                    item, nnfi)
                 if item_changed:
                     queue_changed = True
                 self.reportStats(item)
@@ -1486,7 +1501,6 @@
 
     def onBuildStarted(self, build):
         self.log.debug("Build %s started" % build)
-        self.updateBuildDescriptions(build.build_set)
         return True
 
     def onBuildCompleted(self, build):
@@ -1496,7 +1510,6 @@
         self.pipeline.setResult(item, build)
         self.log.debug("Item %s status is now:\n %s" %
                        (item, item.formatStatus()))
-        self.updateBuildDescriptions(build.build_set)
         return True
 
     def onMergeCompleted(self, event):