Merge "Add indentation to bullet point for clarity"
diff --git a/.bazelproject b/.bazelproject
index b32683a..588db4d 100644
--- a/.bazelproject
+++ b/.bazelproject
@@ -17,6 +17,7 @@
   # BUCK excludes; Remove after we have entirely switched to Bazel
   -./.buckd
   -bucklets
+  -buck-out
 
 targets:
   //...:all
diff --git a/.buckversion b/.buckversion
index 560aff2..7eb591f 100644
--- a/.buckversion
+++ b/.buckversion
@@ -1 +1 @@
-d6949e1440ef2048d697c637a4adae1b509bf72d
+e27df656657f93f8d57a7aaac69a5ae0e298a292
diff --git a/.gitignore b/.gitignore
index c89cfb8..599089a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,3 +32,5 @@
 *.asc
 /bin/
 *~
+.primary_build_tool
+.gwt_work_dir
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 6575018..96b0fd0 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -574,6 +574,12 @@
 `refs/heads/qa/`. See <<project_owners,project owners>> to find
 out more about this role.
 
+For the `All-Projects` root project any `Owner` access right on
+'refs/*' is ignored since this permission would allow users to edit the
+global capabilities, which is the same as being able to administrate
+the Gerrit server (e.g. the user could assign the `Administrate Server`
+capability to the own account).
+
 
 [[category_push]]
 === Push
@@ -824,6 +830,15 @@
 the caller needs to have the Submit permission on `refs/for/<ref>`
 (e.g. on `refs/for/refs/heads/master`).
 
+Submitting to the `refs/meta/config` branch is only allowed to project
+owners. Any explicit submit permissions for non-project-owners on this
+branch are ignored. By submitting to the `refs/meta/config` branch the
+configuration of the project is changed, which can include changes to
+the access rights of the project. Allowing this to be done by a
+non-project-owner would open a security hole enabling editing of access
+rights, and thus granting of powers beyond submitting to the
+configuration.
+
 [[category_submit_on_behalf_of]]
 === Submit (On Behalf Of)
 
diff --git a/Documentation/cmd-review.txt b/Documentation/cmd-review.txt
index 668862b..53e2385 100644
--- a/Documentation/cmd-review.txt
+++ b/Documentation/cmd-review.txt
@@ -14,6 +14,7 @@
   [--submit | -s]
   [--abandon | --restore]
   [--rebase]
+  [--move <BRANCH>]
   [--publish]
   [--json | -j]
   [--delete]
@@ -66,7 +67,7 @@
 	link:rest-api-changes.html#review-input[ReviewInput] entity for the
 	format.
 	(option is mutually exclusive with --submit, --restore, --publish, --delete,
-	--abandon, --message and --rebase)
+	--abandon, --message, --rebase and --move)
 
 --notify::
 -n::
@@ -88,7 +89,7 @@
 --abandon::
 	Abandon the specified change(s).
 	(option is mutually exclusive with --submit, --restore, --publish, --delete,
-	--rebase and --json)
+	--rebase, --move and --json)
 
 --restore::
 	Restore the specified abandoned change(s).
@@ -98,6 +99,10 @@
 	Rebase the specified change(s).
 	(option is mutually exclusive with --abandon, --submit, --delete and --json)
 
+--move::
+	Move the specified change(s).
+	(option is mutually exclusive with --json and --abandon)
+
 --submit::
 -s::
 	Submit the specified patch set(s) for merging.
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 564d298..33c63b2 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -1539,7 +1539,7 @@
 +
 * `MAXDB`
 +
-Connect to an SAP MaxDb database server.
+Connect to an SAP MaxDB database server.
 +
 * `MYSQL`
 +
@@ -1997,6 +1997,23 @@
 by the system administrator, and might not even be running on the
 same host as Gerrit.
 
+[[gerrit.installModule]]gerrit.installModule::
++
+Repeatable list of class name of additional Guice modules to load at
+Gerrit startup and init phases.
+Classes are resolved using the primary Gerrit class loader, hence the
+class needs to be either declared in Gerrit or an additional JAR
+located under the `/lib` directory.
++
+By default unset.
++
+Example:
+----
+[gerrit]
+  installModule = com.googlesource.gerrit.libmodule.MyModule
+  installModule = com.example.abc.OurSpecialSauceModule
+----
+
 [[gerrit.reportBugUrl]]gerrit.reportBugUrl::
 +
 URL to direct users to when they need to report a bug.
@@ -2601,8 +2618,8 @@
 
 ==== Elasticsearch configuration
 
-WARNING: ElasticSearch implementation is incomplete. Right now it is
-still using parts of Lucene index.
+WARNING: The Elasticsearch support is incomplete. Online reindexing
+is not implemented yet.
 
 Open and closed changes are indexed in a single index, separated
 into types 'open_changes' and 'closed_changes' respectively.
@@ -2628,12 +2645,13 @@
 +
 Defauls to `9200`.
 
-[[index.name]]index.name::
+[[index.prefix]]index.prefix::
 +
-This setting can be used to index changes from multiple Gerrit
-instances in a single Elasticsearch cluster.
+This setting can be used to prefix index names to allow multiple Gerrit
+instances in a single Elasticsearch cluster. Prefix 'gerrit1_' would result in a
+change index named 'gerrit1_changes_0001'.
 +
-Defaults to 'gerrit'.
+Not set by default.
 
 [[ldap]]
 === Section ldap
@@ -2897,6 +2915,15 @@
 +
 Default is `cn`.
 
+[[ldap.mandatoryGroup]]ldap.mandatoryGroup::
++
+All users must be a member of this group to allow account creation or
+authentication.
++
+Setting mandatoryGroup implies enabling of `ldap.fetchMemberOfEagerly`
++
+By default, unset.
+
 [[ldap.localUsernameToLowerCase]]ldap.localUsernameToLowerCase::
 +
 Converts the local username, that is used to login into the Gerrit
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index 1f9dd33..5a82d5a 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -230,6 +230,19 @@
 Allowed range of values are 0 (Patch Set Unlocked) to 1 (Patch Set
 Locked).
 
+[[label_allowPostSubmit]]
+=== `label.Label-Name.allowPostSubmit`
+
+If true, the label may be voted on for changes that have already been
+submitted. If false, the label will not appear in the UI and will not
+be accepted when reviewing a closed change.
+
+In either case, voting on a label after submission is only permitted if
+the new vote is at least as high as the old vote by that user. This
+avoids creating the false impression that a post-submit vote can change
+the past and affect submission somehow.
+
+Defaults to true.
 
 [[label_copyMinScore]]
 === `label.Label-Name.copyMinScore`
diff --git a/Documentation/config-plugins.txt b/Documentation/config-plugins.txt
index 6676c9d..3a55b48 100644
--- a/Documentation/config-plugins.txt
+++ b/Documentation/config-plugins.txt
@@ -164,24 +164,24 @@
 Documentation]
 
 [[avatars-external]]
-=== avatars/external
+=== avatars-external
 
 This plugin allows to use an external url to load the avatar images
 from.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/avatars/external[
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/avatars-external[
 Project] |
-link:https://gerrit.googlesource.com/plugins/avatars/external/+doc/master/src/main/resources/Documentation/about.md[
+link:https://gerrit.googlesource.com/plugins/avatars-external/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
-link:https://gerrit.googlesource.com/plugins/avatars/external/+doc/master/src/main/resources/Documentation/config.md[
+link:https://gerrit.googlesource.com/plugins/avatars-external/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
 
 [[avatars-gravatar]]
-=== avatars/gravatar
+=== avatars-gravatar
 
 Plugin to display user icons from Gravatar.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/avatars/gravatar[
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/avatars-gravatar[
 Project]
 
 [[branch-network]]
@@ -658,7 +658,7 @@
 
 This plugin replaces the built-in Gerrit H2 based websession cache with
 a flatfile based implementation. This implementation is shareable
-amongst multiple Gerrit servers, making it useful for multi-master
+among multiple Gerrit servers, making it useful for multi-master
 Gerrit installations.
 
 link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/websession-flatfile[
diff --git a/Documentation/database-setup.txt b/Documentation/database-setup.txt
index 08d0a54..0f73bd3 100644
--- a/Documentation/database-setup.txt
+++ b/Documentation/database-setup.txt
@@ -25,12 +25,13 @@
 database as backend so no set up or configuration is necessary.
 
 Currently only support for embedded mode is added. There are two other
-deployment options for Apache Derby that can be added later [1]:
-+
-* Derby Network Server (standalone mode)
-* Embedded Server (hybrid mode)
-+
-[1] http://db.apache.org/derby/papers/DerbyTut/ns_intro.html#ns
+deployment options for Apache Derby that can be added later:
+
+* link:http://db.apache.org/derby/papers/DerbyTut/ns_intro.html#Network+Server+Options[
+Derby Network Server (standalone mode)]
+
+* link:http://db.apache.org/derby/papers/DerbyTut/ns_intro.html#Embedded+Server[
+Embedded Server (hybrid mode)]
 
 [[createdb_postgres]]
 === PostgreSQL
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 5ac4bea..82d3d0a 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -3,17 +3,16 @@
 Bazel build is experimental. Major missing parts:
 
 * Custom plugins
-* Eclipse project generation.
 * Test suites for SSH, acceptance, etc.
 * tag tests as slow, flaky, etc.
 
 Nice to have:
 
 * JGit build from local tree.
-* Global maven artifact caching.
 * local.properties proxy config.
 * coverage
 
+[[installation]]
 == Installation
 
 You need to use Java 8 and Node.js for building gerrit.
@@ -27,7 +26,8 @@
 
 === Gerrit Development WAR File
 
-To build the Gerrit web application that includes GWT UI and PolyGerrit UI:
+To build the Gerrit web application that includes the GWT UI and the
+PolyGerrit UI:
 
 ----
   bazel build gerrit
@@ -39,11 +39,20 @@
   bazel-bin/gerrit.war
 ----
 
-to run,
+[[release]]
+=== Gerrit Release WAR File
+
+To build the Gerrit web application that includes the GWT UI, the
+PolyGerrit UI and documentation:
 
 ----
-  $(bazel info output_base)/external/local_jdk/bin/java \
-     -jar bazel-bin/release.war daemon -d ../gerrit_testsite
+  bazel build release
+----
+
+The output executable WAR will be placed in:
+
+----
+  bazel-bin/release.war
 ----
 
 === Headless Mode
@@ -57,7 +66,7 @@
 The output executable WAR will be placed in:
 
 ----
-  bazel-bin/headless/headless.war
+  bazel-bin/headless.war
 ----
 
 === Extension and Plugin API JAR Files
@@ -98,7 +107,7 @@
 The output JAR files for individual plugins will be placed in:
 
 ----
-  bazel-bin/plugins/<name>/<name>_deploy.jar
+  bazel-genfiles/plugins/<name>/<name>.jar
 ----
 
 The JAR files will also be packaged in:
@@ -110,13 +119,13 @@
 To build a specific plugin:
 
 ----
-  bazel build plugins/<name>:<name>_deploy.jar
+  bazel build plugins/<name>
 ----
 
 The output JAR file will be be placed in:
 
 ----
-  bazel-bin/plugins/<name>/<name>_deploy.jar
+  bazel-genfiles/plugins/<name>/<name>.jar
 ----
 
 Note that when building an individual plugin, the `core.zip` package
@@ -129,13 +138,26 @@
 
 === IntelliJ
 
-The Gerrit build works with Bazel's [IntelliJ
-plugin](https://ij.bazel.io). Do the following:
+The Gerrit build works with Bazel's link:https://ij.bazel.io[IntelliJ plugin].
+Please follow the instructions on <<dev-intellij#,IntelliJ Setup>>.
 
-  * Install the plugin (requires IJ 2016.2 or newer)
-  * Select "File > Import Bazel project".
-  * Select "Workspace": (directory holding gerrit source)
-  * Select "project view: generate from BUILD": (enter top level BUILD file)
+=== Eclipse
+
+==== Generating the Eclipse Project
+
+Create the Eclipse project:
+
+----
+  tools/eclipse/project_bzl.py
+----
+
+and then follow the link:dev-eclipse.html#setup[setup instructions].
+
+==== Refreshing the Classpath
+
+If an updated classpath is needed, the Eclipse project can be
+refreshed and missing dependency JARs can be downloaded by running
+`project_bzl.py` again.
 
 [[documentation]]
 === Documentation
@@ -164,13 +186,6 @@
   bazel-bin/withdocs.war
 ----
 
-[[release]]
-=== Gerrit Release WAR File
-
-----
-  bazel build release
-----
-
 [[tests]]
 == Running Unit Tests
 
@@ -181,23 +196,35 @@
 Debugging tests:
 
 ----
-  bazel test --test_output=streamed --test_filter=com.gerrit.TestClass.testMethod  //...
+  bazel test --test_output=streamed --test_filter=com.gerrit.TestClass.testMethod  testTarget
+----
+
+Debug test example:
+
+----
+  bazel test --test_output=streamed --test_filter=com.google.gerrit.acceptance.api.change.ChangeIT.getAmbiguous //gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change:api_change
 ----
 
 To run a specific test group, e.g. the rest-account test group:
 
 ----
-  bazel test //gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account:rest-account
+  bazel test //gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account:rest_account
+----
+
+To run the tests against NoteDb backend:
+
+----
+  bazel test --test_env=GERRIT_NOTEDB=READ_WRITE //...
 ----
 
 == Dependencies
 
-Dependency JARs are normally downloaded automatically, but Buck can inspect
-its graph and download any missing JAR files.  This is useful to enable
+Dependency JARs are normally downloaded as needed, but you can
+download everything upfront.  This is useful to enable
 subsequent builds to run without network access:
 
 ----
-  tools/download_all.py
+  bazel fetch //...
 ----
 
 When downloading from behind a proxy (which is common in some corporate
@@ -287,8 +314,9 @@
 the `downloaded-artifacts` directory, which holds the artifacts that got
 downloaded (not built locally).
 
-
-== Known issues and bugs
+[NOTE] When building with Bazel the artifacts are still cached in
+`~/.gerritcodereview/buck-cache/`. This allows Bazel to make use of
+libraries that were previously downloaded by Buck.
 
 GERRIT
 ------
diff --git a/Documentation/dev-buck.txt b/Documentation/dev-buck.txt
index 9deed50..e677ce5 100644
--- a/Documentation/dev-buck.txt
+++ b/Documentation/dev-buck.txt
@@ -341,6 +341,12 @@
 
 The HTML report is created in `buck-out/gen/jacoco/code-coverage/index.html`.
 
+To run the tests against NoteDb backend:
+
+----
+  GERRIT_NOTEDB=READ_WRITE buck test
+----
+
 == Dependencies
 
 Dependency JARs are normally downloaded automatically, but Buck can inspect
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index 775fe21..a5a4f90 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -97,6 +97,7 @@
 ====
 
 
+[[git_commit_settings]]
 === A sample good Gerrit commit message:
 ====
   Add sample commit message to guidelines doc
diff --git a/Documentation/dev-intellij.txt b/Documentation/dev-intellij.txt
new file mode 100644
index 0000000..8f5b4d7
--- /dev/null
+++ b/Documentation/dev-intellij.txt
@@ -0,0 +1,147 @@
+= IntelliJ Setup
+
+== Prerequisites
+You need an installation of IntelliJ of version 2016.2 or newer.
+
+In addition, Java 8 must be specified on your path or via `JAVA_HOME` so that
+building with Bazel via the Bazel plugin is possible.
+
+TIP: If the synchronization of the project with the BUILD files using the Bazel
+plugin fails and IntelliJ reports the error **Could not get Bazel roots**, this
+indicates that the Bazel plugin couldn't find Java 8.
+
+Bazel must be installed as described by
+<<dev-bazel#installation,Building with Bazel - Installation>>.
+
+== Installation of the Bazel plugin
+
+. Go to *File -> Settings -> Plugins*.
+. Click on *Browse Repositories*.
+. Search for the plugin `IntelliJ with Bazel`.
+. Install it.
+. Restart IntelliJ.
+
+== Creation of IntelliJ project
+
+. Go to *File -> Import Bazel Project*.
+. For *Use existing bazel workspace -> Workspace*, select the directory
+containing the Gerrit source code.
+. Choose *Import from workspace* and select the `.bazelproject` file which is
+located in the top directory of the Gerrit source code.
+. Adjust the path of the project data directory and the name of the project if
+desired.
+
+TIP: The project data directory can be separate from the source code. One
+advantage of this is that project files don't need to be excluded from version
+control.
+
+Unfortunately, the created project seems to have a broken output path. To fix
+it, please complete the following steps:
+
+. Go to *File -> Project Structure -> Project Settings -> Modules*.
+. Switch to the tab *Paths*.
+. Click on *Inherit project compile output path*.
+. Click on *Use module compile output path*.
+
+== Recommended settings
+
+=== Code style
+. Go to *File -> Settings -> Editor -> Code Style*.
+. Click on *Manage*.
+. Click on *Import*.
+. Choose `IntelliJ IDEA Code Style XML`.
+. Select the file `$(gerrit_source_code)/tools/intellij/Gerrit_Code_Style.xml`.
+. Make sure that `Google Format (Gerrit)` is chosen as *Scheme*.
+
+In addition, the EditorConfig settings (which ensure a consistent style between
+Eclipse, IntelliJ, and other editors) should be applied on top of that. Those
+settings are in the file `.editorconfig` of the Gerrit source code. IntelliJ
+will automatically pick up those settings if the EditorConfig plugin is enabled
+and configured correctly as can be verified by:
+
+. Go to *File -> Settings -> Plugins*.
+. Ensure that the EditorConfig plugin is enabled.
+. Go to *File -> Settings -> Editor -> Code Style*.
+. Ensure that *Enable EditorConfig support* is checked.
+
+NOTE: If IntelliJ notifies you later on that the EditorConfig settings override
+the code style settings, simply confirm that.
+
+=== Copyright
+Copy the folder `$(gerrit_source_code)/tools/intellij/copyright` (not just the
+contents) to `$(project_data_directory)/.idea`. If it already exists, replace
+it.
+
+=== File header
+By default, IntelliJ adds a file header containing the name of the author and
+the current date to new files. To disable that, follow these steps:
+
+. Go to *File -> Settings -> Editor -> File and Code Templates*.
+. Select the tab *Includes*.
+. Select *File Header*.
+. Remove the template code in the right editor.
+
+=== Commit message
+To simplify the creation of commit messages which are compliant with the
+<<dev-contributing#commit-message,Commit Message>> format, do the following:
+
+. Go to *File -> Settings -> Version Control*.
+. Check *Commit message right margin (columns)*.
+. Make sure that 72 is specified as value.
+. Check *Wrap when typing reaches right margin*.
+
+In addition, you should follow the instructions of
+<<dev-contributing#git_commit_settings,this section>> (if you haven't
+done so already):
+
+* Install the Git hook for the `Change-Id` line.
+* Set up the HTTP access.
+
+Setting up the HTTP access will allow you to commit changes via IntelliJ without
+specifying your credentials. The Git hook won't be noticeable during a commit
+as it's executed after the commit dialog of IntelliJ was closed.
+
+== Run configurations
+Run configurations can be accessed on the toolbar. To edit them or add new ones,
+choose *Edit Configurations* on the drop-down list of the run configurations
+or go to *Run -> Edit Configurations*.
+
+=== Pre-configured run configurations
+
+In order to be able to use the pre-configured run configurations, the following
+steps are necessary:
+
+. Make sure that the folder `runConfigurations` exists within
+`$(project_data_directory)/.idea`. If it doesn't exist, create it.
+. Specify the IntelliJ path variable `GERRIT_TESTSITE`. (This configuration is
+shared among all IntelliJ projects.)
+.. Go to *Settings -> Appearance & Behavior -> Path Variables*.
+.. Click on the *+* to add a new path variable.
+.. Specify `GERRIT_TESTSITE` as name and the path to your local test site as
+value.
+
+The copied run configurations will be added automatically to the available run
+configurations of the IntelliJ project.
+
+==== Gerrit Daemon
+Copy `$(gerrit_source_code)/tools/intellij/gerrit_daemon.xml` to
+`$(project_data_directory)/.idea/runConfigurations/`.
+
+This run configuration starts the Gerrit daemon similarly as
+<<dev-readme#run_daemon,Running the Daemon>>.
+
+NOTE: The <<dev-readme#init,Site Initialization>> has to be completed
+before this run configuration works properly.
+
+=== Unit tests
+To create run configurations for unit tests, run or debug them via a right-click
+on a method, class, file, or package. The created run configuration is a
+temporary one and can be saved to make it permanent.
+
+Normally, this approach generates JUnit run configurations. When the Bazel
+plugin manages a project, it intercepts the creation and creates a Bazel test
+run configuration instead, which can be used just like the standard ones.
+
+TIP: If you would like to execute a test in NoteDb mode, add
+`--test_env=GERRIT_NOTEDB=READ_WRITE` to the *Bazel flags* of your run
+configuration.
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 0b6c474..87e5031 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -482,6 +482,14 @@
 Certain operations in Gerrit can be validated by plugins by
 implementing the corresponding link:config-validation.html[listeners].
 
+[[change-message-modifier]]
+== Change Message Modifier
+
+`com.google.gerrit.server.git.ChangeMessageModifier`:
+plugins implementing this can modify commit message of the change being
+submitted by Rebase Always and Cherry Pick submit strategies as well as
+change being queried with COMMIT_FOOTERS option.
+
 [[receive-pack]]
 == Receive Pack Initializers
 
@@ -643,7 +651,7 @@
 ----
 
 [[search_operators]]
-=== Search Operators ===
+== Search Operators
 
 Plugins can define new search operators to extend change searching by
 implementing the `ChangeQueryBuilder.ChangeOperatorFactory` interface
@@ -684,6 +692,43 @@
 }
 ----
 
+[[search_operands]]
+=== Search Operands ===
+
+Plugins can define new search operands to extend change searching.
+Plugin methods implementing search operands (returning a
+`Predicate<ChangeData>`), must be defined on a class implementing
+one of the `ChangeQueryBuilder.ChangeOperandsFactory` interfaces
+(.e.g., ChangeQueryBuilder.ChangeHasOperandFactory).  The specific
+`ChangeOperandFactory` class must also be bound to the `DynamicSet` from
+a module's `configure()` method in the plugin.
+
+The new operand, when used in a search would appear as:
+  operatorName:operandName_pluginName
+
+A sample `ChangeHasOperandFactory` class implementing, and registering, a
+new `has:sample_pluginName` operand is shown below:
+
+====
+  @Singleton
+  public class SampleHasOperand implements ChangeHasOperandFactory {
+    public static class Module extends AbstractModule {
+      @Override
+      protected void configure() {
+        bind(ChangeHasOperandFactory.class)
+            .annotatedWith(Exports.named("sample")
+            .to(SampleHasOperand.class);
+      }
+    }
+
+    @Override
+    public Predicate<ChangeData> create(ChangeQueryBuilder builder)
+        throws QueryParseException {
+      return new HasSamplePredicate();
+    }
+====
+
+
 [[simple-configuration]]
 == Simple Configuration in `gerrit.config`
 
@@ -1462,6 +1507,52 @@
 });
 ----
 
+
+[[action-visitor]]
+=== Action Visitors
+
+In addition to providing new actions, plugins can have fine-grained control
+over the link:rest-api-changes.html#action-info[ActionInfo] map, modifying or
+removing existing actions, including those contributed by core.
+
+Visitors are provided the link:rest-api-changes.html#action-info[ActionInfo],
+which is mutable, along with copies of the
+link:rest-api-changes.html#change-info[ChangeInfo] and
+link:rest-api-changes.html#revision-info[RevisionInfo]. They can modify the
+action, or return `false` to exclude it from the resulting map.
+
+These operations only affect the action buttons that are displayed in the UI;
+the underlying REST API endpoints are not affected. Multiple plugins may
+implement the visitor interface, but the order in which they are run is
+undefined.
+
+For example, to exclude "Cherry-Pick" only from certain projects, and rename
+"Abandon":
+
+[source,java]
+----
+public class MyActionVisitor implements ActionVisitor {
+  @Override
+  public boolean visit(String name, ActionInfo actionInfo,
+      ChangeInfo changeInfo) {
+    if (name.equals("abandon")) {
+      actionInfo.label = "Drop";
+    }
+    return true;
+  }
+
+  @Override
+  public boolean visit(String name, ActionInfo actionInfo,
+      ChangeInfo changeInfo, RevisionInfo revisionInfo) {
+    if (project.startsWith("some-team/") && name.equals("cherrypick")) {
+      return false;
+    }
+    return true;
+  }
+}
+----
+
+
 [[top-menu-extensions]]
 == Top Menu Extensions
 
@@ -1599,6 +1690,7 @@
 }
 ----
 
+
 [[gwt_ui_extension]]
 == GWT UI Extension
 Plugins can extend the Gerrit UI with own GWT code.
@@ -2254,6 +2346,7 @@
 }
 ----
 
+
 [[documentation]]
 == Documentation
 
diff --git a/Documentation/dev-readme.txt b/Documentation/dev-readme.txt
index bd7f6e9..fe122d7 100644
--- a/Documentation/dev-readme.txt
+++ b/Documentation/dev-readme.txt
@@ -1,6 +1,6 @@
 = Gerrit Code Review - Developer Setup
 
-Facebook Buck is needed to compile the code, and an SQL database to
+Bazel or Facebook Buck is needed to compile the code, and an SQL database to
 house the review metadata.  H2 is recommended for development
 databases, as it requires no external server process.
 
@@ -18,11 +18,11 @@
 the core plugins, which are included as git submodules, are also
 cloned.
 
-
+[[compile_project]]
 == Compiling
 
-For details on how to build the source code with Buck, refer to:
-link:dev-buck.html#build[Building on the command line with Buck].
+Please refer to either <<dev-buck#,Building with Buck>> or
+<<dev-bazel#,Building with Bazel>>.
 
 
 == Switching between branches
@@ -40,6 +40,10 @@
   git clean -fdx
 ----
 
+CAUTION: If you decide to store your Eclipse/IntelliJ project files in the
+Gerrit source directories, executing `git clean -fdx` will remove them and hence
+screw up your project.
+
 
 == Configuring Eclipse
 
@@ -52,6 +56,8 @@
 
 == Configuring IntelliJ IDEA
 
+=== Build based on Buck
+
 To use IntelliJ IDEA for development, the easiest way is to follow
 Eclipse integration and then open it as Eclipse project in IDEA.
 You need the Eclipse plugin activated in IntelliJ IDEA.
@@ -68,6 +74,11 @@
   __server_gen__
 ----
 
+=== Build based on Bazel
+
+Please refer to <<dev-intellij#,IntelliJ Setup>> for detailed
+instructions.
+
 == Mac OS X
 
 On Mac OS X ensure "Java For Mac OS X 10.5 Update 4" (or later) has
@@ -83,13 +94,25 @@
 [[init]]
 == Site Initialization
 
-After compiling (above), run Gerrit's 'init' command to create a
-testing site for development use:
+After compiling <<compile_project,(above)>>, run Gerrit's 'init' command to
+create a testing site for development use:
 
+.Build based on Buck
 ----
   java -jar buck-out/gen/gerrit/gerrit.war init -d ../gerrit_testsite
 ----
 
+.Build based on Bazel
+----
+  $(bazel info output_base)/external/local_jdk/bin/java \
+     -jar bazel-bin/gerrit.war init -d ../gerrit_testsite
+----
+
+[[special_bazel_java_version]]
+NOTE: You must use the same Java version that Bazel used for the build.
+This Java version is available at
+`$(bazel info output_base)/external/local_jdk/bin/java`.
+
 During initialization, make two changes to the default settings:
 
 * Change the listen addresses from '*' to 'localhost' to prevent outside
@@ -149,17 +172,29 @@
 For instructions on running the integration tests with Buck,
 please refer to:
 link:dev-buck.html#tests[Running integration tests with Buck].
+For Bazel, please refer to <<dev-bazel#tests,Running Unit Tests with Bazel>>.
 
-
+[[run_daemon]]
 === Running the Daemon
 
 The daemon can be directly launched from the build area, without
 copying to the test site:
 
+.Build based on Buck
 ----
   java -jar buck-out/gen/gerrit/gerrit.war daemon -d ../gerrit_testsite
 ----
 
+.Build based on Bazel
+----
+  $(bazel info output_base)/external/local_jdk/bin/java \
+     -jar bazel-bin/gerrit.war daemon -d ../gerrit_testsite
+----
+
+NOTE: Please refer to <<special_bazel_java_version,this explanation>>
+for details why using `java -jar` isn't sufficient.
+
+
 === Running the Daemon with Gerrit Inspector
 
 link:dev-inspector.html[Gerrit Inspector] is an interactive scriptable
@@ -175,10 +210,20 @@
 Gerrit Inspect can be started by adding '-s' option to the
 command used to launch the daemon:
 
+.Build based on Buck
 ----
   java -jar buck-out/gen/gerrit/gerrit.war daemon -d ../gerrit_testsite -s
 ----
 
+.Build based on Bazel
+----
+  $(bazel info output_base)/external/local_jdk/bin/java \
+     -jar bazel-bin/gerrit.war daemon -d ../gerrit_testsite -s
+----
+
+NOTE: Please refer to <<special_bazel_java_version,this explanation>>
+for details why using `java -jar` isn't sufficient.
+
 Gerrit Inspector examines Java libraries first, then loads
 its initialization scripts and then starts a command line
 prompt on the console:
@@ -202,10 +247,20 @@
 The embedded H2 database can be queried and updated from the
 command line.  If the daemon is not currently running:
 
+.Build based on Buck
 ----
   java -jar buck-out/gen/gerrit/gerrit.war gsql -d ../gerrit_testsite
 ----
 
+.Build based on Bazel
+----
+  $(bazel info output_base)/external/local_jdk/bin/java \
+     -jar bazel-bin/gerrit.war gsql -d ../gerrit_testsite -s
+----
+
+NOTE: Please refer to <<special_bazel_java_version,this explanation>>
+for details why using `java -jar` isn't sufficient.
+
 Or, if it is running and the database is in use, connect over SSH
 using an administrator user account:
 
diff --git a/Documentation/index.txt b/Documentation/index.txt
index 06a416d..c913aef 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -61,8 +61,10 @@
 == Developer
 . Getting Started
 .. link:dev-readme.html[Developer Setup]
-.. link:dev-eclipse.html[Eclipse Setup]
 .. link:dev-buck.html[Building with Buck]
+.. link:dev-bazel.html[Building with Bazel]
+.. link:dev-eclipse.html[Eclipse Setup]
+.. link:dev-intellij.html[IntelliJ Setup]
 .. link:dev-contributing.html[Contributing to Gerrit]
 . Plugin Development
 .. link:dev-plugins.html[Developing Plugins]
diff --git a/Documentation/install-quick.txt b/Documentation/install-quick.txt
index a8115db..d665226 100644
--- a/Documentation/install-quick.txt
+++ b/Documentation/install-quick.txt
@@ -33,7 +33,7 @@
 
 If Java isn't installed, get it:
 
-* JDK, minimum version 1.8 http://www.oracle.com/technetwork/java/javase/downloads/index.html[Download]
+* JRE, minimum version 1.8 http://www.oracle.com/technetwork/java/javase/downloads/index.html[Download]
 
 
 [[user]]
diff --git a/Documentation/install.txt b/Documentation/install.txt
index 86c9f9a..f0a1730 100644
--- a/Documentation/install.txt
+++ b/Documentation/install.txt
@@ -5,7 +5,7 @@
 To run the Gerrit service, the following requirements must be met on
 the host:
 
-* JDK, minimum version 1.8 http://www.oracle.com/technetwork/java/javase/downloads/index.html[Download]
+* JRE, minimum version 1.8 http://www.oracle.com/technetwork/java/javase/downloads/index.html[Download]
 
 You'll also need an SQL database to house the review metadata. You have the
 choice of either using the embedded H2 or to host your own MySQL or PostgreSQL.
diff --git a/Documentation/intro-project-owner.txt b/Documentation/intro-project-owner.txt
index 72fe717..03eeeb7 100644
--- a/Documentation/intro-project-owner.txt
+++ b/Documentation/intro-project-owner.txt
@@ -330,7 +330,7 @@
 
 A Prolog submit rule has access to link:prolog-change-facts.html[
 information] about the change for which it is testing the
-submittability. Amongst others the list of the modified files can be
+submittability. Among others the list of the modified files can be
 accessed, which allows special logic if certain files are touched. For
 example, a common practice is to require a vote on an additional label,
 like `Library-Compliance`, if the dependencies of the project are
diff --git a/Documentation/intro-quick.txt b/Documentation/intro-quick.txt
index bb80134..c6dad5b 100644
--- a/Documentation/intro-quick.txt
+++ b/Documentation/intro-quick.txt
@@ -208,7 +208,7 @@
 can add file comment by double clicking anywhere (not just on the
 "Patch Set" words) in the table header or single clicking on the icon
 in the line-number column header. Once published these comments are
-viewable to all, allowing discussion of the change to take place.
+visible to all, allowing discussion of the change to take place.
 
 .Side By Side Patch View
 image::images/intro-quick-review-line-comment.jpg[Adding a Comment]
diff --git a/Documentation/js-api.txt b/Documentation/js-api.txt
index 8c9950e..f3b84d2 100644
--- a/Documentation/js-api.txt
+++ b/Documentation/js-api.txt
@@ -701,8 +701,7 @@
 accessed through this name.
 
 [[Gerrit_css]]
-Gerrit.css()
-~~~~~~~~~~~~
+=== Gerrit.css()
 Creates a new unique CSS class and injects it into the document.
 The name of the class is returned and can be used by the plugin.
 See link:#Gerrit_html[`Gerrit.html()`] for an easy way to use
@@ -805,8 +804,7 @@
 The user can return to Gerrit with the back button.
 
 [[Gerrit_html]]
-Gerrit.html()
-~~~~~~~~~~~~~
+=== Gerrit.html()
 Parses an HTML fragment after performing template replacements.  If
 the HTML has a single root element or node that node is returned,
 otherwise it is wrapped inside a `<div>` and the div is returned.
@@ -900,8 +898,7 @@
 ----
 
 [[Gerrit_injectCss]]
-Gerrit.injectCss()
-~~~~~~~~~~~~~~~~~~
+=== Gerrit.injectCss()
 Injects CSS rules into the document by appending onto the end of the
 existing rule list.  CSS rules are global to the entire application
 and must be manually scoped by each plugin.  For an automatic scoping
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index b6d6aad..aef5a318 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -76,6 +76,11 @@
 * `git/upload-pack/phase_writing`: Time spent transferring bytes to client.
 * `git/upload-pack/pack_bytes`: Distribution of sizes of packs sent to clients.
 
+=== BatchUpdate
+
+* `batch_update/execute_change_ops`: BatchUpdate change update latency,
+excluding reindexing
+
 === NoteDb
 
 * `notedb/update_latency`: NoteDb update latency by table.
@@ -86,6 +91,17 @@
 * `notedb/auto_rebuild_failure_count`: NoteDb auto-rebuilding attempts that
 failed by table.
 
+=== Reviewer Suggestion
+
+* `reviewer_suggestion/query_accounts`: Latency for querying accounts for
+reviewer suggestion.
+* `reviewer_suggestion/recommend_accounts`: Latency for recommending accounts
+for reviewer suggestion.
+* `reviewer_suggestion/load_accounts`: Latency for loading accounts for
+reviewer suggestion.
+* `reviewer_suggestion/query_groups`: Latency for querying groups for reviewer
+suggestion.
+
 === Replication Plugin
 
 * `plugins/replication/replication_latency`: Time spent pushing to remote
diff --git a/Documentation/project-configuration.txt b/Documentation/project-configuration.txt
index 2703e4e..54ddcff 100644
--- a/Documentation/project-configuration.txt
+++ b/Documentation/project-configuration.txt
@@ -103,7 +103,8 @@
 link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
 is enabled and depending changes share the same topic. So generally
 submitters must remember to submit changes in the right order when using this
-submit type.
+submit type. If all you want is extra information in the commit message,
+consider using the Rebase Always submit strategy.
 
 [[rebase_if_necessary]]
 * Rebase If Necessary
@@ -120,8 +121,12 @@
 [[rebase_always]]
 * Rebase Always
 +
-Basically, the same as Rebase If Necesary, but it creates a new patchset even if
-fast forward is possible. In this regard, it's similar to Cherry Pick, but with
+Basically, the same as Rebase If Necessary, but it creates a new patchset even
+if fast forward is possible AND like Cherry Pick it ensures footers such as
+Change-Id, Reviewed-On, and others are present in resulting commit that is
+merged.
+
+Thus, Rebase Always can be considered similar to Cherry Pick, but with
 the important distinction that Rebase Always does not ignore dependencies.
 
 [[content_merge]]
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 47030cf..89728120 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -1945,6 +1945,24 @@
   "Individual"
 ----
 
+[[index-account]]
+=== Index Account
+--
+'POST /accounts/link:#account-id[\{account-id\}]/index'
+--
+
+Adds or updates the account in the secondary index.
+
+.Request
+----
+  POST /accounts/1000096/index HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
 [[ids]]
 == IDs
 
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 43b829c..d60385c 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -246,17 +246,17 @@
 
 [[current-files]]
 --
-* `CURRENT_FILES`: list files modified by the commit, including
-  basic line counts inserted/deleted per file. Only valid when
-  the `CURRENT_REVISION` or `ALL_REVISIONS` option is selected.
+* `CURRENT_FILES`: list files modified by the commit and magic files,
+  including basic line counts inserted/deleted per file. Only valid
+  when the `CURRENT_REVISION` or `ALL_REVISIONS` option is selected.
 --
 
 [[all-files]]
 --
-* `ALL_FILES`: list files modified by the commit, including
-  basic line counts inserted/deleted per file. If only the
-  `CURRENT_REVISION` was requested then only that commit's
-  modified files will be output.
+* `ALL_FILES`: list files modified by the commit and magic files,
+  including basic line counts inserted/deleted per file. If only the
+  `CURRENT_REVISION` was requested then only that commit's modified
+  files will be output.
 --
 
 [[detailed-accounts]]
@@ -2792,6 +2792,64 @@
 Adding query parameter `links` (for example `/changes/.../commit?links`)
 returns a link:#commit-info[CommitInfo] with the additional field `web_links`.
 
+[[get-description]]
+=== Get Description
+--
+'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/description'
+--
+
+Retrieves the description of a patch set.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/description HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  "Added Documentation"
+----
+
+If the patch set does not have a description an empty string is returned.
+
+[[set-description]]
+=== Set Description
+--
+'PUT /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/description'
+--
+
+Sets the description of a patch set.
+
+The new description must be provided in the request body inside a
+link:#description-input[DescriptionInput] entity.
+
+.Request
+----
+  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/description HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "description": "Added Documentation"
+  }
+----
+
+As response the new description is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  "Added Documentation"
+----
+
 [[get-merge-list]]
 === Get Merge List
 --
@@ -3565,7 +3623,9 @@
 ----
 
 If the `other-branches` parameter is specified, the mergeability will also be
-checked for all other branches.
+checked for all other branches which are listed in the
+link:config-project-config.html#branchOrder-section[branchOrder] section in the
+project.config file.
 
 .Request
 ----
@@ -3969,7 +4029,7 @@
   }
 ----
 
-[[list-comments]]
+[[list-robot-comments]]
 === List Robot Comments
 --
 'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/robotcomments/'
@@ -4073,6 +4133,18 @@
 
 Lists the files that were modified, added or deleted in a revision.
 
+In addition the following magic files are included:
+
+* `/COMMIT_MSG`:
++
+The commit message and headers with the parent commit(s), the author
+information and the committer information.
+
+* `/MERGE_LIST` (for merge commits only):
++
+The list of commits that are being integrated into the destination
+branch by submitting the merge commit.
+
 .Request
 ----
   GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/files/ HTTP/1.0
@@ -4561,6 +4633,79 @@
   }
 ----
 
+[[get-hashtags]]
+=== Get Hashtags
+--
+'GET /changes/link:#change-id[\{change-id\}]/hashtags'
+--
+
+Gets the hashtags associated with a change.
+
+[NOTE] Hashtags are only available when NoteDb is enabled.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/hashtags HTTP/1.0
+----
+
+As response the change's hashtags are returned as a list of strings.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    "hashtag1",
+    "hashtag2"
+  ]
+----
+
+[[set-hashtags]]
+=== Set Hashtags
+--
+'POST /changes/link:#change-id[\{change-id\}]/hashtags'
+--
+
+Adds and/or removes hashtags from a change.
+
+[NOTE] Hashtags are only available when NoteDb is enabled.
+
+The hashtags to add or remove must be provided in the request body inside a
+link:#hashtags-input[HashtagsInput] entity.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/hashtags HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "add" : [
+      "hashtag3"
+    ],
+    "remove" : [
+      "hashtag2"
+    ]
+  }
+----
+
+As response the change's hashtags are returned as a list of strings.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    "hashtag1",
+    "hashtag3"
+  ]
+----
+
 [[ids]]
 == IDs
 
@@ -4595,8 +4740,7 @@
 The name of the label.
 
 [[file-id]]
-\{file-id\}
-~~~~~~~~~~~~
+=== \{file-id\}
 The path of the file.
 
 [[revision-id]]
@@ -4696,14 +4840,19 @@
 
 [options="header",cols="1,^1,5"]
 |===========================
-|Field Name    ||Description
-|`value`       |optional|
+|Field Name               ||Description
+|`value`                  |optional|
 The vote that the user has given for the label. If present and zero, the
 user is permitted to vote on the label. If absent, the user is not
 permitted to vote on that label.
-|`date`        |optional|
+|`permitted_voting_range` |optional|
+The link:#voting-range-info[VotingRangeInfo] the user is authorized to vote
+on that label. If present, the user is permitted to vote on the label
+regarding the range values. If absent, the user is not permitted to vote
+on that label.
+|`date`                   |optional|
 The time and date describing when the approval was made.
-|`tag`                 |optional|
+|`tag`                    |optional|
 Value of the `tag` field from link:#review-input[ReviewInput] set
 while posting the review.
 NOTE: To apply different tags on on different votes/comments multiple
@@ -5095,6 +5244,16 @@
 If not set, the default is `ALL`.
 |=======================
 
+[[description-input]]
+=== DescriptionInput
+The `DescriptionInput` entity contains information for setting a description.
+
+[options="header",cols="1,6"]
+|===========================
+|Field Name     |Description
+|`description`  |The description text.
+|===========================
+
 [[diff-content]]
 === DiffContent
 The `DiffContent` entity contains information about the content differences
@@ -5309,10 +5468,23 @@
 [options="header",cols="1,6"]
 |==========================
 |Field Name    |Description
-|`id`          |The id of the group.
+|`id`          |The UUID of the group.
 |`name`        |The name of the group.
 |==========================
 
+[[hashtags-input]]
+=== HashtagsInput
+
+The `HashtagsInput` entity contains information about hashtags to add to,
+and/or remove from, a change.
+
+[options="header",cols="1,^1,5"]
+|=======================
+|Field Name||Description
+|`add`     |optional|The list of hashtags to be added to the change.
+|`remove   |optional|The list of hashtags to be removed from the change.
+|=======================
+
 [[included-in-info]]
 === IncludedInInfo
 The `IncludedInInfo` entity contains information about the branches a
@@ -5962,6 +6134,18 @@
 The topic will be deleted if not set.
 |===========================
 
+[[voting-range-info]]
+=== VotingRangeInfo
+The `VotingRangeInfo` entity describes the continuous voting range from min
+to max values.
+
+[options="header",cols="1,6"]
+|======================
+|Field Name|Description
+|`min`     |The minimum voting value.
+|`max`     |The maximum voting value.
+|======================
+
 [[web-link-info]]
 === WebLinkInfo
 The `WebLinkInfo` entity describes a link to an external site.
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 7246786..82d9f3e 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -407,7 +407,7 @@
 +
 Returns the cache names as JSON list.
 +
-The cache names are alphabetically sorted.
+The cache names are lexicographically sorted.
 +
 .Request
 ----
diff --git a/Documentation/rest-api-groups.txt b/Documentation/rest-api-groups.txt
index 23d4c5b..e43c4bc 100644
--- a/Documentation/rest-api-groups.txt
+++ b/Documentation/rest-api-groups.txt
@@ -1207,11 +1207,6 @@
 [[ids]]
 == IDs
 
-[[account-id]]
-=== link:rest-api-accounts.html#account-id[\{account-id\}]
---
---
-
 [[group-id]]
 === \{group-id\}
 Identifier for a group.
@@ -1319,7 +1314,7 @@
 name. +
 If not set, the new group will be self-owned.
 |`members`       |optional|The initial members in a list of +
-link:#account-id[account ids].
+link:rest-api-accounts.html#account-id[account ids].
 |===========================
 
 [[group-options-info]]
@@ -1360,8 +1355,7 @@
 |==========================
 
 [[members-input]]
-MembersInput
-~~~~~~~~~~~
+=== MembersInput
 The `MembersInput` entity contains information about accounts that should
 be added as members to a group or that should be deleted from the group.
 
@@ -1369,11 +1363,11 @@
 |==========================
 |Field Name   ||Description
 |`_one_member`|optional|
-The link:#account-id[id] of one account that should be added or
-deleted.
-|`members`    |optional|
-A list of link:#account-id[account ids] that identify the accounts that
+The link:rest-api-accounts.html#account-id[id] of one account that
 should be added or deleted.
+|`members`    |optional|
+A list of link:rest-api-accounts.html#account-id[account ids] that
+identify the accounts that should be added or deleted.
 |==========================
 
 
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 457a287..65e0266 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -2408,8 +2408,7 @@
 |======================================================
 
 [[config-parameter-info]]
-ConfigParameterInfo
-~~~~~~~~~~~~~~~~~~~
+=== ConfigParameterInfo
 The `ConfigParameterInfo` entity describes a project configuration
 parameter.
 
diff --git a/Documentation/user-review-ui.txt b/Documentation/user-review-ui.txt
index 838a433..4803d83 100644
--- a/Documentation/user-review-ui.txt
+++ b/Documentation/user-review-ui.txt
@@ -297,6 +297,23 @@
 
 image::images/user-review-ui-change-screen-file-list.png[width=800, link="images/user-review-ui-change-screen-file-list.png"]
 
+[[magic-files]]
+In addition to the modified files the file list contains magic files
+that are generated by Gerrit and which don't exist in the repository.
+The magic files contain additional commit data that should be
+reviewable and allow users to comment on this data. The magic files are
+always listed first. The following magic files exist:
+
+* `Commit Message`:
++
+The commit message and headers with the parent commit(s), the author
+information and the committer information.
+
+* `Merge List` (for merge commits only):
++
+The list of commits that are being integrated into the destination
+branch by submitting the merge commit.
+
 [[change-screen-mark-reviewed]]
 The checkboxes in front of the file names allow files to be marked as reviewed.
 
diff --git a/Documentation/user-submodules.txt b/Documentation/user-submodules.txt
index 2754b45..13d0755 100644
--- a/Documentation/user-submodules.txt
+++ b/Documentation/user-submodules.txt
@@ -28,7 +28,7 @@
 
 When a commit in a project is merged, Gerrit checks for superprojects
 that are subscribed to the the project and automatically updates those
-superprojects with a commit that updates the gilink for the project.
+superprojects with a commit that updates the gitlink for the project.
 
 This feature is enabled by default and can be disabled
 via link:config-gerrit.html#submodule.enableSuperProjectSubscriptions[submodule.enableSuperProjectSubscriptions]
diff --git a/WORKSPACE b/WORKSPACE
index cd19c74..251b4f5 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -1,4 +1,5 @@
 workspace(name="gerrit")
+load("//tools/bzl:maven_jar.bzl", "maven_jar", "GERRIT", "MAVEN_LOCAL")
 
 ANTLR_VERS = '3.5.2'
 
@@ -24,6 +25,7 @@
   name = 'antlr27',
   artifact = 'antlr:antlr:2.7.7',
   sha1 = '83cd2cd674a217ade95a4bb83a8a14f351f48bd0',
+  attach_source = False,
 )
 
 GUICE_VERS = '4.1.0'
@@ -88,30 +90,35 @@
   name = 'javax_validation',
   artifact = 'javax.validation:validation-api:1.0.0.GA',
   sha1 = 'b6bd7f9d78f6fdaa3c37dae18a4bd298915f328e',
+  src_sha1 = '7a561191db2203550fbfa40d534d4997624cd369',
 )
 
 maven_jar(
   name = 'jsinterop_annotations',
   artifact = 'com.google.jsinterop:jsinterop-annotations:1.0.0',
   sha1 = '23c3a3c060ffe4817e67673cc8294e154b0a4a95',
+  src_sha1 = '5d7c478efbfccc191430d7c118d7bd2635e43750',
 )
 
 maven_jar(
   name = 'ant',
   artifact = 'ant:ant:1.6.5',
   sha1 = '7d18faf23df1a5c3a43613952e0e8a182664564b',
+  attach_source = False,
 )
 
 maven_jar(
   name = 'colt',
   artifact = 'colt:colt:1.2.0',
   sha1 = '0abc984f3adc760684d49e0f11ddf167ba516d4f',
+  attach_source = False,
 )
 
 maven_jar(
   name = 'tapestry',
   artifact = 'tapestry:tapestry:4.0.2',
   sha1 = 'e855a807425d522e958cbce8697f21e9d679b1f7',
+  attach_source = False,
 )
 
 maven_jar(
@@ -120,69 +127,52 @@
   sha1 = 'cdb2dcb4e22b83d6b32b93095f644c3462739e82',
 )
 
-http_jar(
-  name = "javax_validation_src",
-  url = "http://repo1.maven.org/maven2/javax/validation/validation-api/1.0.0.GA/validation-api-1.0.0.GA-sources.jar",
-  sha256 = 'a394d52a9b7fe2bb14f0718d2b3c8308ffe8f37e911956012398d55c9f9f9b54',
-)
-
-http_jar(
-  name = "jsinterop_annotations_src",
-  url = "http://central.maven.org/maven2/com/google/jsinterop/jsinterop-annotations/1.0.0/jsinterop-annotations-1.0.0-sources.jar",
-  sha256 = '80d63c117736ae2fb9837b7a39576f3f0c5bd19cd75127886550c77b4c478f87',
-)
-
 load('//lib/jgit:jgit.bzl', 'JGIT_VERS')
 
 maven_jar(
   name = 'jgit',
   artifact = 'org.eclipse.jgit:org.eclipse.jgit:' + JGIT_VERS,
-  sha1 = '3e3d0b73dcf4ad649f37758ea8502d92f3d299de',
+  sha1 = '34315f71bb9becf6ff75947a9c43c415b929ec21',
+  src_sha1 = '8320c18472870904eb7fb860af353fea818d07e4',
+  repository = GERRIT,
+  unsign = True,
 )
 
 maven_jar(
   name = 'jgit_servlet',
   artifact = 'org.eclipse.jgit:org.eclipse.jgit.http.server:' + JGIT_VERS,
-  sha1 = '6e36638888918d9941dddec7e2abe1f162cc74d9',
-)
-
-# TODO(davido): Remove this hack when maven_jar supports pulling sources
-# https://github.com/bazelbuild/bazel/issues/308
-http_file(
-  name = 'jgit_src',
-  sha256 = '426bf32d097a846a247d5fb1d258fcde1707dec3362b8a62c68785b953c2ae65',
-  url = 'http://repo1.maven.org/maven2/org/eclipse/jgit/org.eclipse.jgit/' +
-      '%s/org.eclipse.jgit-%s-sources.jar' % (JGIT_VERS, JGIT_VERS),
+  sha1 = '927990025d2970995dbb58f03763eeb776fec8fd',
+  repository = GERRIT,
+  unsign = True,
 )
 
 maven_jar(
   name = 'javaewah',
-  artifact = 'com.googlecode.javaewah:JavaEWAH:0.7.9',
-  sha1 = 'eceaf316a8faf0e794296ebe158ae110c7d72a5a',
+  artifact = 'com.googlecode.javaewah:JavaEWAH:1.1.6',
+  sha1 = '94ad16d728b374d65bd897625f3fbb3da223a2b6',
+  attach_source = False,
 )
 
 maven_jar(
   name = 'jgit_archive',
   artifact = 'org.eclipse.jgit:org.eclipse.jgit.archive:' + JGIT_VERS,
-  sha1 = '2db2e7666672a31fa41b7e1dadcba51df6d30954',
+  sha1 = '4a5d058915400c1ef497bfeeb5e87d235213e273',
+  repository = GERRIT,
 )
 
 maven_jar(
   name = 'jgit_junit',
   artifact = 'org.eclipse.jgit:org.eclipse.jgit.junit:' + JGIT_VERS,
-  sha1 = 'e8fb1d81f588c3174a9730bdecdbde9faa04140a',
+  sha1 = '8e3cb9b1f632fdfea76b04c286a2c0d8d260ebce',
+  repository = GERRIT,
+  unsign = True,
 )
 
 maven_jar(
   name = 'gwtjsonrpc',
   artifact = 'com.google.gerrit:gwtjsonrpc:1.11',
   sha1 = '0990e7eec9eec3a15661edcf9232acbac4aeacec',
-)
-
-http_jar(
-  name = 'gwtjsonrpc_src',
-  sha256 = 'fc503488872c022073e244015fcb6806a64b65afe546bdac2db167a3875fb418',
-  url = 'http://repo.maven.apache.org/maven2/com/google/gerrit/gwtjsonrpc/1.11/gwtjsonrpc-1.11-sources.jar',
+  src_sha1 = 'a682afc46284fb58197a173cb5818770a1e7834a',
 )
 
 maven_jar(
@@ -195,12 +185,7 @@
   name = 'gwtorm_client',
   artifact = 'com.google.gerrit:gwtorm:1.16',
   sha1 = '3e41b6d7bb352fa0539ce23b9bce97cf8c26c3bf',
-)
-
-http_jar(
-  name = 'gwtorm_client_src',
-  sha256 = 'd3e482c9ac1f828aa853debe6545c16503fbbde3bda94b18f652d9830b7f84b1',
-  url = 'http://repo.maven.apache.org/maven2/com/google/gerrit/gwtorm/1.16/gwtorm-1.16-sources.jar',
+  src_sha1 = 'f45b7bacc79a0e5a7f6cf799a2dba23cc5bca19b',
 )
 
 maven_jar(
@@ -411,6 +396,12 @@
   sha1 = '6720c93d14225c3e12c4a69768a0370c80e376a3',
 )
 
+maven_jar(
+  name = 'jsoup',
+  artifact = 'org.jsoup:jsoup:1.9.2',
+  sha1 = '5e3bda828a80c7a21dfbe2308d1755759c2fd7b4',
+)
+
 OW2_VERS = '5.1'
 
 maven_jar(
@@ -540,36 +531,41 @@
   name = 'mime_util',
   artifact = 'eu.medsea.mimeutil:mime-util:2.1.3',
   sha1 = '0c9cfae15c74f62491d4f28def0dff1dabe52a47',
+  attach_source = False,
 )
 
 PROLOG_VERS = '1.4.2'
 
 maven_jar(
   name = 'prolog_runtime',
-  repository = 'http://gerrit-maven.storage.googleapis.com/',
+  repository = GERRIT,
   artifact = 'com.googlecode.prolog-cafe:prolog-runtime:' + PROLOG_VERS,
   sha1 = '4421b4806b6e3a318680f6ab1d57569e857169c6',
+  attach_source = False,
 )
 
 maven_jar(
   name = 'prolog_compiler',
-  repository = 'http://gerrit-maven.storage.googleapis.com/',
+  repository = GERRIT,
   artifact = 'com.googlecode.prolog-cafe:prolog-compiler:' + PROLOG_VERS,
   sha1 = '7e5a7ca5efe7db7f69e015cf492f8f04665244d8',
+  attach_source = False,
 )
 
 maven_jar(
   name = 'prolog_io',
-  repository = 'http://gerrit-maven.storage.googleapis.com/',
+  repository = GERRIT,
   artifact = 'com.googlecode.prolog-cafe:prolog-io:' + PROLOG_VERS,
   sha1 = 'd177f6211d1013e0f31a507127f5c87a7f6941f3',
+  attach_source = False,
 )
 
 maven_jar(
   name = 'cafeteria',
-  repository = 'http://gerrit-maven.storage.googleapis.com/',
+  repository = GERRIT,
   artifact = 'com.googlecode.prolog-cafe:prolog-cafeteria:' + PROLOG_VERS,
   sha1 = '11f396cb2588b65e6a78070488aaa58d12bf000e',
+  attach_source = False,
 )
 
 maven_jar(
@@ -586,9 +582,10 @@
 
 maven_jar(
   name = 'blame_cache',
-  repository = 'http://gerrit-maven.storage.googleapis.com/',
+  repository = GERRIT,
   artifact = 'com/google/gitiles:blame-cache:0.1-9',
   sha1 = '51d35e6f8bbc2412265066cea9653dd758c95826',
+  attach_source = False,
 )
 
 # Keep this version of Soy synchronized with the version used in Gitiles.
@@ -768,6 +765,7 @@
   name = 'derby',
   artifact = 'org.apache.derby:derby:10.11.1.1',
   sha1 = 'df4b50061e8e4c348ce243b921f53ee63ba9bbe1',
+  attach_source = False,
 )
 
 JETTY_VERS = '9.3.11.v20160721'
@@ -842,6 +840,7 @@
   name = 'xerces',
   artifact = 'xerces:xercesImpl:2.8.1',
   sha1 = '25101e37ec0c907db6f0612cbf106ee519c1aef1',
+  attach_source = False,
 )
 
 maven_jar(
@@ -868,6 +867,7 @@
   name = 'diff_match_patch',
   artifact = 'org.webjars:google-diff-match-patch:20121119-1',
   sha1 = '0cf1782dbcb8359d95070da9176059a5a9d37709',
+  attach_source = False,
 )
 
 maven_jar(
@@ -981,7 +981,9 @@
   name = 'httpcore_niossl',
   artifact = 'org.apache.httpcomponents:httpcore-niossl:4.0-alpha6',
   sha1 = '9c662e7247ca8ceb1de5de629f685c9ef3e4ab58',
+  attach_source = False,
 )
+
 load("//tools/bzl:js.bzl", "npm_binary", "bower_archive")
 
 npm_binary(
@@ -990,12 +992,12 @@
 
 npm_binary(
   name = "vulcanize",
-  repository = "GERRIT",
+  repository = GERRIT,
 )
 
 npm_binary(
   name = "crisper",
-  repository = "GERRIT",
+  repository = GERRIT,
 )
 
 # bower_archive() seed components.
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 1910049..b65d764 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
 import static com.google.gerrit.reviewdb.client.Patch.MERGE_LIST;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 
 import com.google.common.base.Strings;
@@ -30,6 +31,7 @@
 import com.google.common.collect.Sets;
 import com.google.common.primitives.Chars;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.ContributorAgreement;
 import com.google.gerrit.common.data.GroupReference;
@@ -138,6 +140,7 @@
 import java.io.InputStream;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.EnumSet;
 import java.util.HashMap;
@@ -896,6 +899,13 @@
       .actions();
   }
 
+  protected String getETag(String id) throws Exception {
+    return gApi.changes()
+        .id(id)
+        .current()
+        .etag();
+  }
+
   private static Iterable<String> changeIds(Iterable<ChangeInfo> changes) {
     return Iterables.transform(changes, i -> i.changeId);
   }
@@ -1138,4 +1148,27 @@
     assertThat(contentEntry.editB).isNull();
     assertThat(contentEntry.skip).isNull();
   }
+
+  protected TestRepository<?> createProjectWithPush(String name,
+      @Nullable Project.NameKey parent,
+      SubmitType submitType) throws Exception {
+    Project.NameKey project = createProject(name, parent, true, submitType);
+    grant(Permission.PUSH, project, "refs/heads/*");
+    grant(Permission.SUBMIT, project, "refs/for/refs/heads/*");
+    return cloneProject(project);
+  }
+
+  protected void assertPermitted(ChangeInfo info, String label,
+      Integer... expected) {
+    assertThat(info.permittedLabels).isNotNull();
+    Collection<String> strs = info.permittedLabels.get(label);
+    if (expected.length == 0) {
+      assertThat(strs).isNull();
+    } else {
+      assertThat(
+              strs.stream().map(s -> Integer.valueOf(s.trim()))
+                  .collect(toList()))
+          .containsExactlyElementsIn(Arrays.asList(expected));
+    }
+  }
 }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PluginDaemonTest.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PluginDaemonTest.java
index dde1875..dd73a83 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PluginDaemonTest.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PluginDaemonTest.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.gerrit.launcher.GerritLauncher;
 import com.google.gerrit.server.config.SitePaths;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -40,7 +41,9 @@
 public abstract class PluginDaemonTest extends AbstractDaemonTest {
 
   private static final String BUCKLC = "buck";
+  private static final String BAZELLC = "bazel";
   private static final String BUCKOUT = "buck-out";
+  private static final String BAZELOUT = "bazel-out";
   private static final String ECLIPSE = "eclipse-out";
 
   private Path gen;
@@ -49,6 +52,8 @@
   private Path pluginSubPath;
   private Path pluginSource;
   private boolean standalone;
+  private boolean bazel;
+  private Path basePath;
 
   protected String pluginName;
   protected Path testSite;
@@ -87,10 +92,10 @@
     return cfg;
   }
 
-  private void locatePaths() {
+  private void locatePaths() throws IOException {
     URL pluginClassesUrl =
         getClass().getProtectionDomain().getCodeSource().getLocation();
-    Path basePath = Paths.get(pluginClassesUrl.getPath()).getParent();
+    basePath = Paths.get(pluginClassesUrl.getPath()).getParent();
 
     int idx = 0;
     int buckOutIdx = 0;
@@ -99,14 +104,25 @@
       if (subPath.endsWith("plugins")) {
         pluginsIdx = idx;
       }
+      if (subPath.endsWith(BAZELOUT)) {
+        bazel = true;
+        buckOutIdx = idx;
+      }
+      // TODO(davido): Fix Bazel plugin test from Eclipse
       if (subPath.endsWith(BUCKOUT) || subPath.endsWith(ECLIPSE)) {
         buckOutIdx = idx;
       }
       idx++;
     }
     standalone = checkStandalone(basePath);
-    pluginRoot = basePath.getRoot().resolve(basePath.subpath(0, buckOutIdx));
-    gen = pluginRoot.resolve(BUCKOUT).resolve("gen");
+
+    if (bazel) {
+      pluginRoot = GerritLauncher.resolveInSourceRoot(".");
+      gen = pluginRoot.resolve("bazel-out/local-fastbuild/genfiles");
+    } else {
+      pluginRoot = basePath.getRoot().resolve(basePath.subpath(0, buckOutIdx));
+      gen = pluginRoot.resolve(BUCKOUT).resolve("gen");
+    }
 
     if (standalone) {
       pluginSource = pluginRoot;
@@ -117,6 +133,10 @@
   }
 
   private boolean checkStandalone(Path basePath) {
+    // TODO(davido): Fix Bazel standalone mode
+    if (bazel) {
+      return false;
+    }
     String pathCharStringOrNone = "[a-zA-Z0-9._-]*?";
     Pattern pattern = Pattern.compile(pathCharStringOrNone + "gerrit" +
         pathCharStringOrNone);
@@ -139,8 +159,19 @@
   }
 
   private void retrievePluginName() throws IOException {
-    Path buckFile = pluginSource.resolve("BUCK");
-    byte[] bytes = Files.readAllBytes(buckFile);
+    if (bazel) {
+      pluginName = basePath.getFileName().toString();
+      return;
+    }
+    Path buildfile = pluginSource.resolve("BUCK");
+    if (!Files.exists(buildfile)) {
+      buildfile = pluginSource.resolve("BUILD");
+    }
+    if (!Files.exists(buildfile)) {
+      throw new IllegalStateException("Cannot find build file in: "
+          + pluginSource);
+    }
+    byte[] bytes = Files.readAllBytes(buildfile);
     String buckContent =
         new String(bytes, UTF_8).replaceAll("\\s+", "");
     Matcher matcher =
@@ -158,9 +189,19 @@
   }
 
   private void buildPluginJar() throws IOException, InterruptedException {
-    Properties properties = loadBuckProperties();
-    String buck =
-        MoreObjects.firstNonNull(properties.getProperty(BUCKLC), BUCKLC);
+    Path dir = pluginRoot;
+    String build;
+    if (bazel) {
+      dir = GerritLauncher.resolveInSourceRoot(".");
+      Properties properties = loadBuildProperties(
+          dir.resolve(".primary_build_tool"));
+      build = MoreObjects.firstNonNull(
+          properties.getProperty(BAZELLC), BAZELLC);
+    } else {
+      Properties properties = loadBuildProperties(
+          gen.resolve(Paths.get("tools/buck/buck.properties")));
+      build = MoreObjects.firstNonNull(properties.getProperty(BUCKLC), BUCKLC);
+    }
     String target;
     if (standalone) {
       target = "//:" + pluginName;
@@ -169,16 +210,16 @@
     }
 
     ProcessBuilder processBuilder =
-        new ProcessBuilder(buck, "build", target).directory(pluginRoot.toFile())
+        new ProcessBuilder(build, "build", target).directory(dir.toFile())
             .redirectErrorStream(true);
-    // otherwise plugin jar creation fails:
-    processBuilder.environment().put("NO_BUCKD", "1");
-
     Path forceJar = pluginSource.resolve("src/main/java/ForceJarIfMissing.java");
-    // if exists after cancelled test:
-    Files.deleteIfExists(forceJar);
-
-    Files.createFile(forceJar);
+    if (!bazel) {
+      // otherwise plugin jar creation fails:
+      processBuilder.environment().put("NO_BUCKD", "1");
+      // if exists after cancelled test:
+      Files.deleteIfExists(forceJar);
+      Files.createFile(forceJar);
+    }
     testSite = tempSiteDir.getRoot().toPath();
 
     // otherwise process often hangs:
@@ -189,15 +230,14 @@
     try {
       processBuilder.start().waitFor();
     } finally {
-      Files.delete(forceJar);
+      Files.deleteIfExists(forceJar);
       // otherwise jar not made next time if missing again:
       processBuilder.start().waitFor();
     }
   }
 
-  private Properties loadBuckProperties() throws IOException {
+  private Properties loadBuildProperties(Path propertiesPath) throws IOException {
     Properties properties = new Properties();
-    Path propertiesPath = gen.resolve(Paths.get("tools/buck/buck.properties"));
     if (Files.exists(propertiesPath)) {
       try (InputStream in = Files.newInputStream(propertiesPath)) {
         properties.load(in);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 01009aa..fb1e517 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -705,6 +705,23 @@
     assertThat(info.get(1).seq).isEqualTo(3);
   }
 
+  // reindex is tested by {@link AbstractQueryAccountsTest#reindex}
+  @Test
+  public void reindexPermissions() throws Exception {
+    // admin can reindex any account
+    setApiUser(admin);
+    gApi.accounts().id(user.username).index();
+
+    // user can reindex own account
+    setApiUser(user);
+    gApi.accounts().self().index();
+
+    // user cannot reindex any account
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to index account");
+    gApi.accounts().id(admin.username).index();
+  }
+
   private void assertSequenceNumbers(List<SshKeyInfo> sshKeys) {
     int seq = 1;
     for (SshKeyInfo key : sshKeys) {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/BUILD
index 9935eeb..6caddb6 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/BUILD
@@ -3,5 +3,5 @@
 acceptance_tests(
   group = 'api_account',
   srcs = glob(['*IT.java']),
-  labels = ['api'],
+  labels = ['api', 'noci'],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
index 1a43784..f523354 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -75,8 +75,9 @@
     GeneralPreferencesInfo o = gApi.accounts()
         .id(user42.id.toString())
         .getPreferences();
-    assertPrefs(o, GeneralPreferencesInfo.defaults(), "my");
+    assertPrefs(o, GeneralPreferencesInfo.defaults(), "my", "changeTable");
     assertThat(o.my).hasSize(7);
+    assertThat(o.changeTable).isEmpty();
 
     GeneralPreferencesInfo i = GeneralPreferencesInfo.defaults();
 
@@ -99,6 +100,8 @@
     i.diffView = DiffView.UNIFIED_DIFF;
     i.my = new ArrayList<>();
     i.my.add(new MenuItem("name", "url"));
+    i.changeTable = new ArrayList<>();
+    i.changeTable.add("Status");
     i.urlAliases = new HashMap<>();
     i.urlAliases.put("foo", "bar");
 
@@ -107,6 +110,7 @@
         .setPreferences(i);
     assertPrefs(o, i, "my");
     assertThat(o.my).hasSize(1);
+    assertThat(o.changeTable).hasSize(1);
   }
 
   @Test
@@ -125,6 +129,6 @@
     assertThat(o.changesPerPage).isEqualTo(newChangesPerPage);
 
     // assert hard-coded defaults
-    assertPrefs(o, d, "my", "changesPerPage");
+    assertPrefs(o, d, "my", "changeTable", "changesPerPage");
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/BUILD
index 2502cad..371b03a 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/BUILD
@@ -3,5 +3,5 @@
 acceptance_tests(
   group = 'api_change',
   srcs = glob(['*IT.java']),
-  labels = ['api'],
+  labels = ['api', 'noci'],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
index de907ca..c52989d 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -16,6 +16,8 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
@@ -34,6 +36,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
 import com.google.gerrit.acceptance.GerritConfig;
@@ -53,9 +56,11 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
@@ -68,6 +73,8 @@
 import com.google.gerrit.extensions.common.MergeInput;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -86,6 +93,7 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
 import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ChangeControl;
@@ -101,6 +109,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PushResult;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -111,6 +120,7 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.EnumSet;
+import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
@@ -122,6 +132,9 @@
   @Inject
   private BatchUpdate.Factory updateFactory;
 
+  @Inject
+  private DynamicSet<ChangeMessageModifier> changeMessageModifiers;
+
   @Before
   public void setTimeForTesting() {
     systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
@@ -707,6 +720,48 @@
   }
 
   @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void changeNoParentToOneParent() throws Exception {
+    // create initial commit with no parent and push it as change, so that patch
+    // set 1 has no parent
+    RevCommit c =
+        testRepo.commit().message("Initial commit").insertChangeId().create();
+    String id = GitUtil.getChangeId(testRepo, c).get();
+    testRepo.reset(c);
+
+    PushResult pr = pushHead(testRepo, "refs/for/master", false);
+    assertPushOk(pr, "refs/for/master");
+
+    ChangeInfo change = gApi.changes().id(id).get();
+    assertThat(change.revisions.get(change.currentRevision).commit.parents)
+        .isEmpty();
+
+    // create another initial commit with no parent and push it directly into
+    // the remote repository
+    c = testRepo.amend(c.getId()).message("Initial Empty Commit").create();
+    testRepo.reset(c);
+    pr = pushHead(testRepo, "refs/heads/master", false);
+    assertPushOk(pr, "refs/heads/master");
+
+    // create a successor commit and push it as second patch set to the change,
+    // so that patch set 2 has 1 parent
+    RevCommit c2 = testRepo.commit().message("Initial commit").parent(c)
+        .insertChangeId(id.substring(1)).create();
+    testRepo.reset(c2);
+
+    pr = pushHead(testRepo, "refs/for/master", false);
+    assertPushOk(pr, "refs/for/master");
+
+    change = gApi.changes().id(id).get();
+    RevisionInfo rev = change.revisions.get(change.currentRevision);
+    assertThat(rev.commit.parents).hasSize(1);
+    assertThat(rev.commit.parents.get(0).commit).isEqualTo(c.name());
+
+    // check that change kind is correctly detected as REWORK
+    assertThat(rev.kind).isEqualTo(ChangeKind.REWORK);
+  }
+
+  @Test
   public void pushCommitOfOtherUser() throws Exception {
     // admin pushes commit of user
     PushOneCommit push = pushFactory.create(db, user.getIdent(), testRepo);
@@ -1103,6 +1158,31 @@
   }
 
   @Test
+  public void emailNotificationForFileLevelComment() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes()
+        .id(changeId)
+        .addReviewer(in);
+    sender.clear();
+
+    ReviewInput review = new ReviewInput();
+    ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
+    comment.path = PushOneCommit.FILE_NAME;
+    comment.side = Side.REVISION;
+    comment.message = "comment 1";
+    review.comments = new HashMap<>();
+    review.comments.put(comment.path, Lists.newArrayList(comment));
+    gApi.changes().id(changeId).current().review(review);
+
+    assertThat(sender.getMessages()).hasSize(1);
+    Message m = sender.getMessages().get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+  }
+
+  @Test
   public void listVotes() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes()
@@ -1736,6 +1816,41 @@
   }
 
   @Test
+  public void customCommitFooters() throws Exception {
+    PushOneCommit.Result change = createChange();
+    RegistrationHandle handle =
+        changeMessageModifiers.add(new ChangeMessageModifier() {
+          @Override
+          public String onSubmit(String newCommitMessage, RevCommit original,
+              RevCommit mergeTip, Branch.NameKey destination) {
+            assertThat(original.getName()).isNotEqualTo(mergeTip.getName());
+            return newCommitMessage + "Custom: " + destination.get();
+          }
+        });
+    ChangeInfo actual;
+    try {
+      EnumSet<ListChangesOption> options = EnumSet.of(
+          ListChangesOption.ALL_REVISIONS, ListChangesOption.COMMIT_FOOTERS);
+      actual = gApi.changes().id(change.getChangeId()).get(options);
+    } finally {
+      handle.remove();
+    }
+    List<String> footers = new ArrayList<>(Arrays.asList(
+        actual.revisions.get(change.getCommit().getName()).commitWithFooters
+            .split("\\n")));
+    // remove subject + blank line
+    footers.remove(0);
+    footers.remove(0);
+
+    List<String> expectedFooters =
+        Arrays.asList(
+            "Change-Id: " + change.getChangeId(), "Reviewed-on: "
+                + canonicalWebUrl.get() + change.getChange().getId(),
+            "Custom: refs/heads/master");
+    assertThat(footers).containsExactlyElementsIn(expectedFooters);
+  }
+
+  @Test
   public void defaultSearchDoesNotTouchDatabase() throws Exception {
     setApiUser(admin);
     PushOneCommit.Result r1 = createChange();
@@ -2230,6 +2345,8 @@
         .containsExactly("Code-Review", "Verified");
     assertThat(change.permittedLabels.keySet())
         .containsExactly("Code-Review", "Verified");
+    assertPermitted(change, "Code-Review", -2, -1, 0, 1, 2);
+    assertPermitted(change, "Verified", -1, 0, 1);
 
     // add an approval on the new label
     gApi.changes()
@@ -2270,6 +2387,7 @@
     assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
     assertThat(change.labels.keySet()).containsExactly("Code-Review");
     assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+    assertPermitted(change, "Code-Review", 2);
 
     // add new label and assert that it's returned for existing changes
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
@@ -2289,6 +2407,8 @@
         .containsExactly("Code-Review", "Verified");
     assertThat(change.permittedLabels.keySet())
         .containsExactly("Code-Review", "Verified");
+    assertPermitted(change, "Code-Review", 2);
+    assertPermitted(change, "Verified", 0, 1);
 
     // ignore the new label by Prolog submit rule and assert that the label is
     // no longer returned
@@ -2304,8 +2424,8 @@
     change = gApi.changes()
         .id(r.getChangeId())
         .get();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+    assertPermitted(change, "Code-Review", 2);
+    assertPermitted(change, "Verified");
 
     // add an approval on the new label and assert that the label is now
     // returned although it is ignored by the Prolog submit rule and hence not
@@ -2321,7 +2441,8 @@
         .get();
     assertThat(change.labels.keySet())
         .containsExactly("Code-Review", "Verified");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+    assertPermitted(change, "Code-Review", 2);
+    assertPermitted(change, "Verified");
 
     // remove label and assert that it's no longer returned for existing
     // changes, even if there is an approval for it
@@ -2336,6 +2457,66 @@
         .get();
     assertThat(change.labels.keySet()).containsExactly("Code-Review");
     assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+    assertPermitted(change, "Code-Review", 2);
+  }
+
+  @Test
+  public void checkLabelsForMergedChangeWithNonAuthorCodeReview()
+      throws Exception {
+    // Configure Non-Author-Code-Review
+    RevCommit oldHead = getRemoteHead();
+    GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
+    testRepo.reset("config");
+    PushOneCommit push2 = pushFactory.create(db, admin.getIdent(), testRepo,
+        "Configure Non-Author-Code-Review",
+        "rules.pl",
+        "submit_rule(S) :-\n"
+            + "  gerrit:default_submit(X),\n"
+            + "  X =.. [submit | Ls],\n"
+            + "  add_non_author_approval(Ls, R),\n"
+            + "  S =.. [submit | R].\n"
+            + "\n"
+            + "add_non_author_approval(S1, S2) :-\n"
+            + "  gerrit:commit_author(A),\n"
+            + "  gerrit:commit_label(label('Code-Review', 2), R),\n"
+            + "  R \\= A, !,\n"
+            + "  S2 = [label('Non-Author-Code-Review', ok(R)) | S1].\n"
+            + "add_non_author_approval(S1,"
+            + " [label('Non-Author-Code-Review', need(_)) | S1]).");
+    push2.to(RefNames.REFS_CONFIG);
+    testRepo.reset(oldHead);
+
+    // Allow user to approve
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    AccountGroup.UUID registeredUsers =
+        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    String heads = RefNames.REFS_HEADS + "*";
+    Util.allow(cfg, Permission.forLabel(Util.codeReview().getName()), -2, 2,
+        registeredUsers, heads);
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r = createChange();
+
+    setApiUser(user);
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(ReviewInput.approve());
+
+    setApiUser(admin);
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .submit();
+
+    ChangeInfo change = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.labels.keySet()).containsExactly("Code-Review",
+        "Non-Author-Code-Review");
+    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+    assertPermitted(change, "Code-Review", 0, 1, 2);
   }
 
   @Test
@@ -2351,7 +2532,7 @@
         .get();
     assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
     assertThat(change.labels.keySet()).containsExactly("Code-Review");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+    assertPermitted(change, "Code-Review", 0, 1, 2);
   }
 
   @Test
@@ -2369,6 +2550,68 @@
     assertThat(change.permittedLabels).isEmpty();
   }
 
+  @Test
+  public void maxPermittedValueAllowed() throws Exception {
+    final int minPermittedValue = -2;
+    final int maxPermittedValue = +2;
+    String heads = "refs/heads/*";
+
+    PushOneCommit.Result r = createChange();
+    String triplet = project.get() + "~master~" + r.getChangeId();
+
+    gApi.changes().id(triplet).addReviewer(user.username);
+
+    ChangeInfo c = gApi.changes()
+      .id(triplet)
+      .get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+    LabelInfo codeReview = c.labels.get("Code-Review");
+    assertThat(codeReview.all).hasSize(1);
+    ApprovalInfo approval = codeReview.all.get(0);
+    assertThat(approval._accountId).isEqualTo(user.id.get());
+    assertThat(approval.permittedVotingRange).isNotNull();
+    // default values
+    assertThat(approval.permittedVotingRange.min).isEqualTo(-1);
+    assertThat(approval.permittedVotingRange.max).isEqualTo(1);
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    Util.allow(cfg,
+      Permission.forLabel("Code-Review"), minPermittedValue, maxPermittedValue,
+      REGISTERED_USERS, heads);
+    saveProjectConfig(project, cfg);
+
+    c = gApi.changes()
+      .id(triplet)
+      .get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+    codeReview = c.labels.get("Code-Review");
+    assertThat(codeReview.all).hasSize(1);
+    approval = codeReview.all.get(0);
+    assertThat(approval._accountId).isEqualTo(user.id.get());
+    assertThat(approval.permittedVotingRange).isNotNull();
+    assertThat(approval.permittedVotingRange.min).isEqualTo(minPermittedValue);
+    assertThat(approval.permittedVotingRange.max).isEqualTo(maxPermittedValue);
+  }
+
+  @Test
+  public void maxPermittedValueBlocked() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    blockLabel(cfg, "Code-Review", REGISTERED_USERS, "refs/heads/*");
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r = createChange();
+    String triplet = project.get() + "~master~" + r.getChangeId();
+
+    gApi.changes().id(triplet).addReviewer(user.username);
+
+    ChangeInfo c = gApi.changes()
+      .id(triplet)
+      .get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+    LabelInfo codeReview = c.labels.get("Code-Review");
+    assertThat(codeReview.all).hasSize(1);
+    ApprovalInfo approval = codeReview.all.get(0);
+    assertThat(approval._accountId).isEqualTo(user.id.get());
+    assertThat(approval.permittedVotingRange).isNull();
+  }
+
   private static Iterable<Account.Id> getReviewers(
       Collection<AccountInfo> r) {
     return Iterables.transform(r, a -> new Account.Id(a._accountId));
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 4da22d3..dd2fd3a 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.extensions.client.ChangeKind.MERGE_FIRST_PARENT_UPDATE;
+import static com.google.gerrit.extensions.client.ChangeKind.NO_CHANGE;
 import static com.google.gerrit.extensions.client.ChangeKind.NO_CODE_CHANGE;
 import static com.google.gerrit.extensions.client.ChangeKind.REWORK;
 import static com.google.gerrit.extensions.client.ChangeKind.TRIVIAL_REBASE;
@@ -72,10 +73,12 @@
             value(0, "No score"),
             value(-1, "I would prefer that you didn't submit this"),
             value(-2, "Do not submit"));
+    codeReview.setCopyAllScoresIfNoChange(false);
     cfg.getLabelSections().put(codeReview.getName(), codeReview);
 
     LabelType verified = category("Verified", value(1, "Passes"),
         value(0, "No score"), value(-1, "Failed"));
+    verified.setCopyAllScoresIfNoChange(false);
     cfg.getLabelSections().put(verified.getName(), verified);
 
     AccountGroup.UUID registeredUsers =
@@ -91,7 +94,7 @@
   @Test
   public void notSticky() throws Exception {
     assertNotSticky(EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE,
-        MERGE_FIRST_PARENT_UPDATE));
+        MERGE_FIRST_PARENT_UPDATE, NO_CHANGE));
   }
 
   @Test
@@ -101,7 +104,7 @@
     saveProjectConfig(project, cfg);
 
     for (ChangeKind changeKind : EnumSet.of(REWORK, TRIVIAL_REBASE,
-        NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE)) {
+        NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(getRemoteHead());
 
       String changeId = createChange(changeKind);
@@ -122,7 +125,7 @@
     saveProjectConfig(project, cfg);
 
     for (ChangeKind changeKind : EnumSet.of(REWORK, TRIVIAL_REBASE,
-        NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE)) {
+        NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(getRemoteHead());
 
       String changeId = createChange(changeKind);
@@ -147,8 +150,13 @@
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    updateChange(changeId, TRIVIAL_REBASE);
+    updateChange(changeId, NO_CHANGE);
     ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, NO_CHANGE);
+    assertVotes(c, user, -2, 0, NO_CHANGE);
+
+    updateChange(changeId, TRIVIAL_REBASE);
+    c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, TRIVIAL_REBASE);
     assertVotes(c, user, -2, 0, TRIVIAL_REBASE);
 
@@ -189,8 +197,13 @@
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    updateChange(changeId, NO_CODE_CHANGE);
+    updateChange(changeId, NO_CHANGE);
     ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 0, 1, NO_CHANGE);
+    assertVotes(c, user, 0, -1, NO_CHANGE);
+
+    updateChange(changeId, NO_CODE_CHANGE);
+    c = detailedChange(changeId);
     assertVotes(c, admin, 0, 1, NO_CODE_CHANGE);
     assertVotes(c, user, 0, -1, NO_CODE_CHANGE);
 
@@ -209,8 +222,13 @@
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    updateChange(changeId, MERGE_FIRST_PARENT_UPDATE);
+    updateChange(changeId, NO_CHANGE);
     ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, NO_CHANGE);
+    assertVotes(c, user, -2, 0, NO_CHANGE);
+
+    updateChange(changeId, MERGE_FIRST_PARENT_UPDATE);
+    c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, MERGE_FIRST_PARENT_UPDATE);
     assertVotes(c, user, -2, 0, MERGE_FIRST_PARENT_UPDATE);
 
@@ -226,7 +244,7 @@
     saveProjectConfig(project, cfg);
 
     for (ChangeKind changeKind : EnumSet.of(REWORK, TRIVIAL_REBASE,
-        NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE)) {
+        NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(getRemoteHead());
 
       String changeId = createChange(changeKind);
@@ -319,7 +337,6 @@
         .setCopyMaxScore(true);
     saveProjectConfig(project, cfg);
 
-
     // Vote max score on PS1
     String changeId = createChange(REWORK);
     vote(admin, changeId, label, 2);
@@ -384,6 +401,8 @@
         updateFirstParent(changeId);
         return;
       case NO_CHANGE:
+        noChange(changeId);
+        return;
       default:
         fail("unexpected change kind: " + changeKind);
     }
@@ -400,6 +419,21 @@
     assertThat(getChangeKind(changeId)).isEqualTo(NO_CODE_CHANGE);
   }
 
+  private void noChange(String changeId) throws Exception {
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    String commitMessage =
+        change.revisions.get(change.currentRevision).commit.message;
+
+    TestRepository<?>.CommitBuilder commitBuilder =
+        testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
+    commitBuilder.message(commitMessage)
+        .author(admin.getIdent())
+        .committer(new PersonIdent(admin.getIdent(), testRepo.getDate()));
+    commitBuilder.create();
+    GitUtil.pushHead(testRepo, "refs/for/master", false);
+    assertThat(getChangeKind(changeId)).isEqualTo(NO_CHANGE);
+  }
+
   private void rework(String changeId) throws Exception {
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo,
         PushOneCommit.SUBJECT, PushOneCommit.FILE_NAME,
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 2925c1d..ab4daf2 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.acceptance.PushOneCommit.PATCH;
 import static com.google.gerrit.acceptance.PushOneCommit.PATCH_FILE_ONLY;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
 import static com.google.gerrit.reviewdb.client.Patch.MERGE_LIST;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -44,7 +45,6 @@
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -87,6 +87,7 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Optional;
 
 public class RevisionIT extends AbstractDaemonTest {
 
@@ -166,6 +167,9 @@
     approval = getApproval(changeId, label);
     assertThat(approval.value).isEqualTo(1);
     assertThat(approval.postSubmit).isNull();
+    assertPermitted(
+        gApi.changes().id(changeId).get(EnumSet.of(DETAILED_LABELS)),
+        "Code-Review", 1, 2);
 
     // Repeating the current label is allowed. Does not flip the postSubmit bit
     // due to deduplication codepath.
@@ -200,6 +204,9 @@
     approval = getApproval(changeId, label);
     assertThat(approval.value).isEqualTo(2);
     assertThat(approval.postSubmit).isTrue();
+    assertPermitted(
+        gApi.changes().id(changeId).get(EnumSet.of(DETAILED_LABELS)),
+        "Code-Review", 2);
 
     // Decreasing to previous post-submit vote is still not allowed.
     try {
@@ -217,6 +224,53 @@
     assertThat(approval.postSubmit).isTrue();
   }
 
+  @Test
+  public void postSubmitApprovalAfterVoteRemoved() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = project.get() + "~master~" + r.getChangeId();
+
+    setApiUser(admin);
+    revision(r).review(ReviewInput.approve());
+
+    setApiUser(user);
+    revision(r).review(ReviewInput.recommend());
+
+    setApiUser(admin);
+    gApi.changes()
+        .id(changeId)
+        .reviewer(user.username)
+        .deleteVote("Code-Review");
+    Optional<ApprovalInfo> crUser = get(changeId, DETAILED_LABELS)
+        .labels.get("Code-Review").all.stream()
+        .filter(a -> a._accountId == user.id.get()).findFirst();
+    assertThat(crUser.isPresent()).isTrue();
+    assertThat(crUser.get().value).isEqualTo(0);
+
+    revision(r).submit();
+
+    setApiUser(user);
+    ReviewInput in = new ReviewInput();
+    in.label("Code-Review", 0);
+    in.message = "Still LGTM";
+    revision(r).review(in);
+  }
+
+  @Test
+  public void postSubmitDeleteApprovalNotAllowed() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    revision(r).review(ReviewInput.approve());
+    revision(r).submit();
+
+    ReviewInput in = new ReviewInput();
+    in.label("Code-Review", 0);
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(
+        "Cannot reduce vote on labels for closed change: Code-Review");
+    revision(r).review(in);
+  }
+
   @TestProjectInput(submitType = SubmitType.CHERRY_PICK)
   @Test
   public void approvalCopiedDuringSubmitIsNotPostSubmit() throws Exception {
@@ -771,6 +825,31 @@
   }
 
   @Test
+  public void description() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .description()).isEqualTo("");
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .description("test");
+    assertThat(gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .description()).isEqualTo("test");
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .description("");
+    assertThat(gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .description()).isEqualTo("");
+  }
+
+  @Test
   public void content() throws Exception {
     PushOneCommit.Result r = createChange();
     assertContent(r, FILE_NAME, FILE_CONTENT);
@@ -946,11 +1025,11 @@
   public void actions() throws Exception {
     PushOneCommit.Result r = createChange();
     assertThat(current(r).actions().keySet())
-        .containsExactly("cherrypick", "rebase");
+        .containsExactly("cherrypick", "description", "rebase");
 
     current(r).review(ReviewInput.approve());
     assertThat(current(r).actions().keySet())
-        .containsExactly("submit", "cherrypick", "rebase");
+        .containsExactly("submit", "cherrypick", "description", "rebase");
 
     current(r).submit();
     assertThat(current(r).actions().keySet())
@@ -1090,7 +1169,7 @@
       throws Exception {
     ChangeInfo info = gApi.changes()
         .id(changeId)
-        .get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+        .get(EnumSet.of(DETAILED_LABELS));
     LabelInfo li = info.labels.get(label);
     assertThat(li).isNotNull();
     int accountId = atrScope.get().getUser().getAccountId().get();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
index 7aa5876..145caa3 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -39,6 +39,13 @@
     assume().that(notesMigration.enabled()).isTrue();
 
     PushOneCommit.Result r = createChange();
+
+    Map<String, List<RobotCommentInfo>> out = gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .robotComments();
+    assertThat(out).isEmpty();
+
     RobotCommentInput in = createRobotCommentInput();
     ReviewInput reviewInput = new ReviewInput();
     Map<String, List<RobotCommentInput>> robotComments = new HashMap<>();
@@ -50,7 +57,7 @@
        .current()
        .review(reviewInput);
 
-    Map<String, List<RobotCommentInfo>> out = gApi.changes()
+    out = gApi.changes()
         .id(r.getChangeId())
         .revision(r.getCommit().name())
         .robotComments();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 9927c15..cfdc226 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -26,6 +26,7 @@
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static org.junit.Assert.fail;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -34,6 +35,7 @@
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
@@ -81,6 +83,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.stream.Collectors;
 
 @NoHttpd
 public abstract class AbstractSubmit extends AbstractDaemonTest {
@@ -347,26 +350,126 @@
   }
 
   @Test
+  public void submitWholeTopicMultipleProjects() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+    String topic = "test-topic";
+
+    // Create test projects
+    TestRepository<?> repoA = createProjectWithPush(
+        "project-a", null, getSubmitType());
+    TestRepository<?> repoB = createProjectWithPush(
+        "project-b", null, getSubmitType());
+
+    // Create changes on project-a
+    PushOneCommit.Result change1 =
+        createChange(repoA, "master", "Change 1", "a.txt", "content", topic);
+    PushOneCommit.Result change2 =
+        createChange(repoA, "master", "Change 2", "b.txt", "content", topic);
+
+    // Create changes on project-b
+    PushOneCommit.Result change3 =
+        createChange(repoB, "master", "Change 3", "a.txt", "content", topic);
+    PushOneCommit.Result change4 =
+        createChange(repoB, "master", "Change 4", "b.txt", "content", topic);
+
+    approve(change1.getChangeId());
+    approve(change2.getChangeId());
+    approve(change3.getChangeId());
+    approve(change4.getChangeId());
+    submit(change4.getChangeId());
+
+    String expectedTopic = name(topic);
+    change1.assertChange(Change.Status.MERGED, expectedTopic, admin);
+    change2.assertChange(Change.Status.MERGED, expectedTopic, admin);
+    change3.assertChange(Change.Status.MERGED, expectedTopic, admin);
+    change4.assertChange(Change.Status.MERGED, expectedTopic, admin);
+  }
+
+  @Test
+  public void submitWholeTopicMultipleBranchesOnSameProject() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+    String topic = "test-topic";
+
+    // Create test project
+    String projectName = "project-a";
+    TestRepository<?> repoA = createProjectWithPush(
+        projectName, null, getSubmitType());
+
+    RevCommit initialHead =
+        getRemoteHead(new Project.NameKey(name(projectName)), "master");
+
+    // Create the dev branch on the test project
+    BranchInput in = new BranchInput();
+    in.revision = initialHead.name();
+    gApi.projects().name(name(projectName)).branch("dev").create(in);
+
+    // Create changes on master
+    PushOneCommit.Result change1 =
+        createChange(repoA, "master", "Change 1", "a.txt", "content", topic);
+    PushOneCommit.Result change2 =
+        createChange(repoA, "master", "Change 2", "b.txt", "content", topic);
+
+    // Create  changes on dev
+    repoA.reset(initialHead);
+    PushOneCommit.Result change3 =
+        createChange(repoA, "dev", "Change 3", "a.txt", "content", topic);
+    PushOneCommit.Result change4 =
+        createChange(repoA, "dev", "Change 4", "b.txt", "content", topic);
+
+    approve(change1.getChangeId());
+    approve(change2.getChangeId());
+    approve(change3.getChangeId());
+    approve(change4.getChangeId());
+    submit(change4.getChangeId());
+
+    String expectedTopic = name(topic);
+    change1.assertChange(Change.Status.MERGED, expectedTopic, admin);
+    change2.assertChange(Change.Status.MERGED, expectedTopic, admin);
+    change3.assertChange(Change.Status.MERGED, expectedTopic, admin);
+    change4.assertChange(Change.Status.MERGED, expectedTopic, admin);
+  }
+
+  @Test
   public void submitWholeTopic() throws Exception {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
+    String topic = "test-topic";
     PushOneCommit.Result change1 =
-        createChange("Change 1", "a.txt", "content", "test-topic");
+        createChange("Change 1", "a.txt", "content", topic);
     PushOneCommit.Result change2 =
-        createChange("Change 2", "b.txt", "content", "test-topic");
+        createChange("Change 2", "b.txt", "content", topic);
     PushOneCommit.Result change3 =
-        createChange("Change 3", "c.txt", "content", "test-topic");
+        createChange("Change 3", "c.txt", "content", topic);
     approve(change1.getChangeId());
     approve(change2.getChangeId());
     approve(change3.getChangeId());
     submit(change3.getChangeId());
-    change1.assertChange(Change.Status.MERGED, name("test-topic"), admin);
-    change2.assertChange(Change.Status.MERGED, name("test-topic"), admin);
-    change3.assertChange(Change.Status.MERGED, name("test-topic"), admin);
+    String expectedTopic = name(topic);
+    change1.assertChange(Change.Status.MERGED, expectedTopic, admin);
+    change2.assertChange(Change.Status.MERGED, expectedTopic, admin);
+    change3.assertChange(Change.Status.MERGED, expectedTopic, admin);
+
     // Check for the exact change to have the correct submitter.
     assertSubmitter(change3);
     // Also check submitters for changes submitted via the topic relationship.
     assertSubmitter(change1);
     assertSubmitter(change2);
+
+    // Check that the repo has the expected commits
+    List<RevCommit> log = getRemoteLog();
+    List<String> commitsInRepo = log.stream()
+        .map(c -> c.getShortMessage())
+        .collect(Collectors.toList());
+    int expectedCommitCount = getSubmitType() == SubmitType.MERGE_ALWAYS
+        ? 5 // initial commit + 3 commits + merge commit
+        : 4; // initial commit + 3 commits
+    assertThat(log).hasSize(expectedCommitCount);
+
+    assertThat(commitsInRepo).containsAllOf(
+        "Initial empty repository", "Change 1", "Change 2", "Change 3");
+    if (getSubmitType() == SubmitType.MERGE_ALWAYS) {
+      assertThat(commitsInRepo).contains(
+          "Merge changes from topic '" + expectedTopic + "'");
+    }
   }
 
   @Test
@@ -407,6 +510,66 @@
         "A change to be submitted with " + num + " is not visible");
   }
 
+  @Test
+  public void submitChangeWhenParentOfOtherBranchTip() throws Exception {
+    // Chain of two commits
+    // Push both to topic-branch
+    // Push the first commit for review and submit
+    //
+    // C2 -- tip of topic branch
+    //  |
+    // C1 -- pushed for review
+    //  |
+    // C0 -- Master
+    //
+    ProjectConfig config = projectCache.checkedGet(project).getConfig();
+    config.getProject().setCreateNewChangeForAllNotInTarget(
+        InheritableBoolean.TRUE);
+    saveProjectConfig(project, config);
+
+    PushOneCommit push1 = pushFactory.create(db, admin.getIdent(), testRepo,
+        PushOneCommit.SUBJECT, "a.txt", "content");
+    PushOneCommit.Result c1 = push1.to("refs/heads/topic");
+    c1.assertOkStatus();
+    PushOneCommit push2 = pushFactory.create(db, admin.getIdent(), testRepo,
+        PushOneCommit.SUBJECT, "b.txt", "anotherContent");
+    PushOneCommit.Result c2 = push2.to("refs/heads/topic");
+    c2.assertOkStatus();
+
+    PushOneCommit.Result change1 = push1.to("refs/for/master");
+    change1.assertOkStatus();
+
+    approve(change1.getChangeId());
+    submit(change1.getChangeId());
+  }
+
+  @Test
+  public void submitMergeOfNonChangeBranchTip() throws Exception {
+    // Merge a branch with commits that have not been submitted as
+    // changes.
+    //
+    // M  -- mergeCommit (pushed for review and submitted)
+    // | \
+    // |  S -- stable (pushed directly to refs/heads/stable)
+    // | /
+    // I   -- master
+    //
+    RevCommit master = getRemoteHead(project, "master");
+    PushOneCommit stableTip = pushFactory.create(db, admin.getIdent(), testRepo,
+        "Tip of branch stable", "stable.txt", "");
+    PushOneCommit.Result stable = stableTip.to("refs/heads/stable");
+    PushOneCommit mergeCommit = pushFactory.create(db, admin.getIdent(),
+        testRepo, "The merge commit", "merge.txt", "");
+    mergeCommit.setParents(ImmutableList.of(master, stable.getCommit()));
+    PushOneCommit.Result mergeReview = mergeCommit.to("refs/for/master");
+    approve(mergeReview.getChangeId());
+    submit(mergeReview.getChangeId());
+
+    List<RevCommit> log = getRemoteLog();
+    assertThat(log).contains(stable.getCommit());
+    assertThat(log).contains(mergeReview.getCommit());
+  }
+
   private void assertSubmitter(PushOneCommit.Result change) throws Exception {
     ChangeInfo info = get(change.getChangeId(), ListChangesOption.MESSAGES);
     assertThat(info.messages).isNotNull();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ActionsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ActionsIT.java
index 880fe89..2c9159f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ActionsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -15,24 +15,40 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_ACTIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.extensions.api.changes.ActionVisitor;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
-import com.google.gerrit.server.change.GetRevisionActions;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.inject.Inject;
 
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
+import org.junit.After;
+import org.junit.Before;
 import org.junit.Test;
 
+import java.util.EnumSet;
 import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
 
 public class ActionsIT extends AbstractDaemonTest {
   @ConfigSuite.Config
@@ -41,15 +57,33 @@
   }
 
   @Inject
-  private GetRevisionActions getRevisionActions;
+  private ChangeJson.Factory changeJsonFactory;
+
+  @Inject
+  private DynamicSet<ActionVisitor> actionVisitors;
+
+  private RegistrationHandle visitorHandle;
+
+  @Before
+  public void setUp() {
+    visitorHandle = null;
+  }
+
+  @After
+  public void tearDown() {
+    if (visitorHandle != null) {
+      visitorHandle.remove();
+    }
+  }
 
   @Test
   public void revisionActionsOneChangePerTopicUnapproved() throws Exception {
     String changeId = createChangeWithTopic().getChangeId();
     Map<String, ActionInfo> actions = getActions(changeId);
+    assertThat(actions).hasSize(3);
     assertThat(actions).containsKey("cherrypick");
     assertThat(actions).containsKey("rebase");
-    assertThat(actions).hasSize(2);
+    assertThat(actions).containsKey("description");
   }
 
   @Test
@@ -91,16 +125,16 @@
     String parent = createChange().getChangeId();
     String change = createChangeWithTopic().getChangeId();
     approve(change);
-    String etag1 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+    String etag1 = getETag(change);
 
     approve(parent);
-    String etag2 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+    String etag2 = getETag(change);
 
     String changeWithSameTopic = createChangeWithTopic().getChangeId();
-    String etag3 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+    String etag3 = getETag(change);
 
     approve(changeWithSameTopic);
-    String etag4 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+    String etag4 = getETag(change);
 
     if (isSubmitWholeTopicEnabled()) {
       assertThat(ImmutableList.of(etag1, etag2, etag3, etag4)).containsNoDuplicates();
@@ -117,14 +151,14 @@
     approve(change);
 
     setApiUser(user);
-    String etag1 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+    String etag1 = getETag(change);
 
     setApiUser(admin);
     String draft = createDraftWithTopic().getChangeId();
     approve(draft);
 
     setApiUser(user);
-    String etag2 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+    String etag2 = getETag(change);
 
     if (isSubmitWholeTopicEnabled()) {
       assertThat(etag2).isNotEqualTo(etag1);
@@ -140,25 +174,25 @@
     approve(change);
 
     setApiUserAnonymous();
-    String etag1 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+    String etag1 = getETag(change);
 
     setApiUser(admin);
     approve(parent);
 
     setApiUserAnonymous();
-    String etag2 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+    String etag2 = getETag(change);
 
     setApiUser(admin);
     String changeWithSameTopic = createChangeWithTopic().getChangeId();
 
     setApiUserAnonymous();
-    String etag3 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+    String etag3 = getETag(change);
 
     setApiUser(admin);
     approve(changeWithSameTopic);
 
     setApiUserAnonymous();
-    String etag4 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+    String etag4 = getETag(change);
 
     if (isSubmitWholeTopicEnabled()) {
       assertThat(ImmutableList.of(etag1, etag2, etag3, etag4)).containsNoDuplicates();
@@ -177,13 +211,13 @@
     approve(change);
 
     setApiUserAnonymous();
-    String etag1 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+    String etag1 = getETag(change);
 
     setApiUser(admin);
     approve(parent);
 
     setApiUserAnonymous();
-    String etag2 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+    String etag2 = getETag(change);
     assertThat(etag2).isEqualTo(etag1);
   }
 
@@ -277,10 +311,132 @@
     }
   }
 
+  @Test
+  public void changeActionVisitor() throws Exception {
+    String id = createChange().getChangeId();
+    ChangeInfo origChange =
+        gApi.changes().id(id).get(EnumSet.of(ListChangesOption.CHANGE_ACTIONS));
+
+    class Visitor implements ActionVisitor {
+      @Override
+      public boolean visit(String name, ActionInfo actionInfo,
+          ChangeInfo changeInfo) {
+        assertThat(changeInfo).isNotNull();
+        assertThat(changeInfo._number).isEqualTo(origChange._number);
+        if (name.equals("followup")) {
+          return false;
+        }
+        if (name.equals("abandon")) {
+          actionInfo.label = "Abandon All Hope";
+        }
+        return true;
+      }
+
+      @Override
+      public boolean visit(String name, ActionInfo actionInfo,
+          ChangeInfo changeInfo, RevisionInfo revisionInfo) {
+        throw new UnsupportedOperationException();
+      }
+    }
+
+    Map<String, ActionInfo> origActions = origChange.actions;
+    assertThat(origActions.keySet()).containsAllOf("followup", "abandon");
+    assertThat(origActions.get("abandon").label).isEqualTo("Abandon");
+
+    Visitor v = new Visitor();
+    visitorHandle = actionVisitors.add(v);
+
+    Map<String, ActionInfo> newActions = gApi.changes()
+        .id(id)
+        .get(EnumSet.of(ListChangesOption.CHANGE_ACTIONS))
+        .actions;
+
+    Set<String> expectedNames = new TreeSet<>(origActions.keySet());
+    expectedNames.remove("followup");
+    assertThat(newActions.keySet()).isEqualTo(expectedNames);
+
+    ActionInfo abandon = newActions.get("abandon");
+    assertThat(abandon).isNotNull();
+    assertThat(abandon.label).isEqualTo("Abandon All Hope");
+  }
+
+  @Test
+  public void revisionActionVisitor() throws Exception {
+    String id = createChange().getChangeId();
+    ChangeInfo origChange =
+        gApi.changes().id(id).get(EnumSet.of(ListChangesOption.CHANGE_ACTIONS));
+
+    class Visitor implements ActionVisitor {
+      @Override
+      public boolean visit(String name, ActionInfo actionInfo,
+          ChangeInfo changeInfo) {
+        return true; // Do nothing; implicitly called for CURRENT_ACTIONS.
+      }
+
+      @Override
+      public boolean visit(String name, ActionInfo actionInfo,
+          ChangeInfo changeInfo, RevisionInfo revisionInfo) {
+        assertThat(changeInfo).isNotNull();
+        assertThat(changeInfo._number).isEqualTo(origChange._number);
+        assertThat(revisionInfo).isNotNull();
+        assertThat(revisionInfo._number).isEqualTo(1);
+        if (name.equals("cherrypick")) {
+          return false;
+        }
+        if (name.equals("rebase")) {
+          actionInfo.label = "All Your Base";
+        }
+        return true;
+      }
+    }
+
+    Map<String, ActionInfo> origActions =
+        gApi.changes().id(id).current().actions();
+    assertThat(origActions.keySet()).containsAllOf("cherrypick", "rebase");
+    assertThat(origActions.get("rebase").label).isEqualTo("Rebase");
+
+    Visitor v = new Visitor();
+    visitorHandle = actionVisitors.add(v);
+
+    // Test different codepaths within ActionJson...
+    // ...via revision API.
+    visitedRevisionActionsAssertions(
+        origActions, gApi.changes().id(id).current().actions());
+
+    // ...via change API with option.
+    EnumSet<ListChangesOption> opts =
+        EnumSet.of(CURRENT_ACTIONS, CURRENT_REVISION);
+    ChangeInfo changeInfo = gApi.changes().id(id).get(opts);
+    RevisionInfo revisionInfo =
+        Iterables.getOnlyElement(changeInfo.revisions.values());
+    visitedRevisionActionsAssertions(origActions, revisionInfo.actions);
+
+    // ...via ChangeJson directly.
+    ChangeData cd = changeDataFactory.create(
+        db, project, new Change.Id(origChange._number));
+    revisionInfo = changeJsonFactory.create(opts)
+        .getRevisionInfo(
+            cd.changeControl(), Iterables.getOnlyElement(cd.patchSets()));
+    visitedRevisionActionsAssertions(origActions, revisionInfo.actions);
+  }
+
+  private void visitedRevisionActionsAssertions(
+      Map<String, ActionInfo> origActions, Map<String, ActionInfo> newActions) {
+    assertThat(newActions).isNotNull();
+    Set<String> expectedNames = new TreeSet<>(origActions.keySet());
+    expectedNames.remove("cherrypick");
+    assertThat(newActions.keySet()).isEqualTo(expectedNames);
+
+    ActionInfo rebase = newActions.get("rebase");
+    assertThat(rebase).isNotNull();
+    assertThat(rebase.label).isEqualTo("All Your Base");
+  }
+
   private void commonActionsAssertions(Map<String, ActionInfo> actions) {
-    assertThat(actions).hasSize(3);
+    assertThat(actions).hasSize(4);
     assertThat(actions).containsKey("cherrypick");
     assertThat(actions).containsKey("submit");
+    assertThat(actions).containsKey("description");
     assertThat(actions).containsKey("rebase");
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
index b7f09d1..02cf4f8 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
@@ -20,6 +20,7 @@
 import static org.junit.Assert.fail;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.common.data.Permission;
@@ -28,10 +29,13 @@
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.project.Util;
 
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectLoader;
 import org.eclipse.jgit.revwalk.RevObject;
@@ -143,6 +147,36 @@
         .isEqualTo(parent.name);
   }
 
+  @Test
+  public void rejectDoubleInheritance() throws Exception {
+    setApiUser(admin);
+    // Create separate projects to test the config
+    Project.NameKey parent = createProject("projectToInheritFrom");
+    Project.NameKey child = createProject("projectWithMalformedConfig");
+
+    String config = gApi.projects()
+        .name(child.get())
+        .branch(RefNames.REFS_CONFIG).file("project.config").asString();
+
+    // Append and push malformed project config
+    String pattern =  "[access]\n"
+        + "\tinheritFrom = " + allProjects.get() + "\n";
+    String doubleInherit = pattern + "\tinheritFrom = " + parent.get() + "\n";
+    config = config.replace(pattern, doubleInherit);
+
+    TestRepository<InMemoryRepository> childRepo =
+        cloneProject(child, admin);
+    // Fetch meta ref
+    GitUtil.fetch(childRepo, RefNames.REFS_CONFIG + ":cfg");
+    childRepo.reset("cfg");
+    PushOneCommit push = pushFactory.create(
+        db, admin.getIdent(), childRepo, "Subject", "project.config",
+        config);
+    PushOneCommit.Result res = push.to(RefNames.REFS_CONFIG);
+    res.assertErrorStatus();
+    res.assertMessage("cannot inherit from multiple projects");
+  }
+
   private void fetchRefsMetaConfig() throws Exception {
     git().fetch().setRefSpecs(new RefSpec("refs/meta/config:refs/meta/config"))
         .call();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index af43373..0a3b217 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -19,17 +19,23 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.change.Submit.TestSubmitInput;
+import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.git.strategy.CommitMergeStatus;
+import com.google.inject.Inject;
 
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -40,6 +46,8 @@
 import java.util.List;
 
 public class SubmitByCherryPickIT extends AbstractSubmit {
+  @Inject
+  private DynamicSet<ChangeMessageModifier> changeMessageModifiers;
 
   @Override
   protected SubmitType getSubmitType() {
@@ -89,6 +97,31 @@
   }
 
   @Test
+  public void changeMessageOnSubmit() throws Exception {
+    PushOneCommit.Result change = createChange();
+    RegistrationHandle handle =
+        changeMessageModifiers.add(new ChangeMessageModifier() {
+          @Override
+          public String onSubmit(String newCommitMessage, RevCommit original,
+              RevCommit mergeTip, Branch.NameKey destination) {
+            return newCommitMessage + "Custom: " + destination.get();
+          }
+        });
+    try {
+      submit(change.getChangeId());
+    } finally {
+      handle.remove();
+    }
+    testRepo.git().fetch().setRemote("origin").call();
+    ChangeInfo info = get(change.getChangeId());
+    RevCommit c = testRepo.getRevWalk()
+        .parseCommit(ObjectId.fromString(info.currentRevision));
+    testRepo.getRevWalk().parseBody(c);
+    assertThat(c.getFooterLines("Custom")).containsExactly("refs/heads/master");
+    assertThat(c.getFooterLines(FooterConstants.REVIEWED_ON)).hasSize(1);
+  }
+
+  @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
   public void submitWithContentMerge() throws Exception {
     RevCommit initialHead = getRemoteHead();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
index cf9651f..0389417 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
@@ -18,13 +18,25 @@
 
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.server.git.ChangeMessageModifier;
+import com.google.inject.Inject;
 
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
+import java.util.List;
+
 public class SubmitByRebaseAlwaysIT extends AbstractSubmitByRebase {
+  @Inject
+  private DynamicSet<ChangeMessageModifier> changeMessageModifiers;
 
   @Override
   protected SubmitType getSubmitType() {
@@ -50,4 +62,79 @@
     assertRefUpdatedEvents(oldHead, head);
     assertChangeMergedEvents(change.getChangeId(), head.name());
   }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void alwaysAddFooters() throws Exception {
+    PushOneCommit.Result change1 = createChange();
+    PushOneCommit.Result change2 = createChange();
+
+    assertThat(
+        getCurrentCommit(change1).getFooterLines(FooterConstants.REVIEWED_BY))
+            .isEmpty();
+    assertThat(
+        getCurrentCommit(change2).getFooterLines(FooterConstants.REVIEWED_BY))
+            .isEmpty();
+
+    // change1 is a fast-forward, but should be rebased in cherry pick style
+    // anyway, making change2 not a fast-forward, requiring a rebase.
+    approve(change1.getChangeId());
+    submit(change2.getChangeId());
+    // ... but both changes should get reviewed-by footers.
+    assertLatestRevisionHasFooters(change1);
+    assertLatestRevisionHasFooters(change2);
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void changeMessageOnSubmit() throws Exception {
+    PushOneCommit.Result change1 = createChange();
+    PushOneCommit.Result change2 = createChange();
+
+    RegistrationHandle handle =
+        changeMessageModifiers.add(new ChangeMessageModifier() {
+          @Override
+          public String onSubmit(String newCommitMessage, RevCommit original,
+              RevCommit mergeTip, Branch.NameKey destination) {
+            List<String> custom = mergeTip.getFooterLines("Custom");
+            if (!custom.isEmpty()) {
+              newCommitMessage += "Custom-Parent: " + custom.get(0) + "\n";
+            }
+            return newCommitMessage + "Custom: " + destination.get();
+          }
+        });
+    try {
+      // change1 is a fast-forward, but should be rebased in cherry pick style
+      // anyway, making change2 not a fast-forward, requiring a rebase.
+      approve(change1.getChangeId());
+      submit(change2.getChangeId());
+    } finally {
+      handle.remove();
+    }
+    // ... but both changes should get custom footers.
+    assertThat(getCurrentCommit(change1).getFooterLines("Custom"))
+        .containsExactly("refs/heads/master");
+    assertThat(getCurrentCommit(change2).getFooterLines("Custom"))
+        .containsExactly("refs/heads/master");
+    assertThat(getCurrentCommit(change2).getFooterLines("Custom-Parent"))
+        .containsExactly("refs/heads/master");
+  }
+
+  private void assertLatestRevisionHasFooters(PushOneCommit.Result change)
+      throws Exception {
+    RevCommit c = getCurrentCommit(change);
+    assertThat(c.getFooterLines(FooterConstants.CHANGE_ID)).isNotEmpty();
+    assertThat(c.getFooterLines(FooterConstants.REVIEWED_BY)).isNotEmpty();
+    assertThat(c.getFooterLines(FooterConstants.REVIEWED_ON)).isNotEmpty();
+  }
+
+  private RevCommit getCurrentCommit(PushOneCommit.Result change)
+      throws Exception {
+    testRepo.git().fetch().setRemote("origin").call();
+    ChangeInfo info = get(change.getChangeId());
+    RevCommit c = testRepo.getRevWalk()
+        .parseCommit(ObjectId.fromString(info.currentRevision));
+    testRepo.getRevWalk().parseBody(c);
+    return c;
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
index 184b174..7abbc46 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toList;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
@@ -97,11 +99,25 @@
     String changeId = createChange().getChangeId();
     List<SuggestedReviewerInfo> reviewers =
         suggestReviewers(changeId, name("u"), 6);
-    assertThat(reviewers).hasSize(6);
+    assertReviewers(reviewers, ImmutableList.of(user1, user2, user3),
+        ImmutableList.of(group1, group2, group3));
+
     reviewers = suggestReviewers(changeId, name("u"), 5);
-    assertThat(reviewers).hasSize(5);
+    assertReviewers(reviewers, ImmutableList.of(user1, user2, user3),
+        ImmutableList.of(group1, group2));
+
     reviewers = suggestReviewers(changeId, group3.getName(), 10);
+    assertReviewers(reviewers, ImmutableList.of(), ImmutableList.of(group3));
+
+    // Suggested accounts are ordered by activity. All users have no activity,
+    // hence we don't know which of the matching accounts we get when the query
+    // is limited to 1.
+    reviewers = suggestReviewers(changeId, name("u"), 1);
     assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.get(0).account).isNotNull();
+    assertThat(ImmutableList.of(reviewers.get(0).account._accountId))
+        .containsAnyIn(ImmutableList.of(user1, user2, user3).stream()
+            .map(u -> u.id.get()).collect(toList()));
   }
 
   @Test
@@ -460,4 +476,26 @@
     ci.branch = "master";
     return gApi.changes().create(ci).get().changeId;
   }
+
+  private void assertReviewers(List<SuggestedReviewerInfo> actual,
+      List<TestAccount> expectedUsers, List<AccountGroup> expectedGroups) {
+    List<Integer> actualAccountIds = actual.stream()
+        .filter(i -> i.account != null)
+        .map(i -> i.account._accountId)
+        .collect(toList());
+    assertThat(actualAccountIds)
+        .containsExactlyElementsIn(
+            expectedUsers.stream().map(u -> u.id.get()).collect(toList()));
+
+    List<String> actualGroupIds = actual.stream()
+        .filter(i -> i.group != null)
+        .map(i -> i.group.id)
+        .collect(toList());
+    assertThat(actualGroupIds)
+        .containsExactlyElementsIn(
+            expectedGroups.stream()
+                .map(g -> g.getGroupUUID().get())
+                .collect(toList()))
+        .inOrder();
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUCK
index d53e69a..2d3e2da 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUCK
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUCK
@@ -4,16 +4,16 @@
   group = 'rest_project',
   srcs = glob(['*IT.java']),
   deps = [
-    ':branch',
     ':project',
+    ':refassert',
   ],
   labels = ['rest'],
 )
 
 java_library(
-  name = 'branch',
+  name = 'refassert',
   srcs = [
-    'BranchAssert.java',
+    'RefAssert.java',
   ],
   deps = [
     '//lib:truth',
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUILD
index 579171f..fbb6bde 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUILD
@@ -4,16 +4,16 @@
   group = 'rest_project',
   srcs = glob(['*IT.java']),
   deps = [
-    ':branch',
     ':project',
+    ':refassert',
   ],
   labels = ['rest'],
 )
 
 java_library(
-  name = 'branch',
+  name = 'refassert',
   srcs = [
-    'BranchAssert.java',
+    'RefAssert.java',
   ],
   deps = [
     '//lib:truth',
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
index af1383b..3f0c43e 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.acceptance.rest.project.BranchAssert.assertRefNames;
+import static com.google.gerrit.acceptance.rest.project.RefAssert.assertRefNames;
 import static org.junit.Assert.fail;
 
 import com.google.common.collect.ImmutableList;
@@ -25,6 +25,7 @@
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.RefNames;
 
@@ -107,6 +108,31 @@
     assertBranchesDeleted();
   }
 
+  @Test
+  public void missingInput() throws Exception {
+    DeleteBranchesInput input = null;
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("branches must be specified");
+    project().deleteBranches(input);
+  }
+
+  @Test
+  public void missingBranchList() throws Exception {
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("branches must be specified");
+    project().deleteBranches(input);
+  }
+
+  @Test
+  public void emptyBranchList() throws Exception {
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    input.branches = Lists.newArrayList();
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("branches must be specified");
+    project().deleteBranches(input);
+  }
+
   private String errorMessageForBranches(List<String> branches) {
     StringBuilder message = new StringBuilder();
     for (String branch : branches) {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
index 7c98188..5728217 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
-import static com.google.gerrit.acceptance.rest.project.BranchAssert.assertBranches;
-import static com.google.gerrit.acceptance.rest.project.BranchAssert.assertRefNames;
+import static com.google.gerrit.acceptance.rest.project.RefAssert.assertRefNames;
+import static com.google.gerrit.acceptance.rest.project.RefAssert.assertRefs;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -47,7 +47,7 @@
   @Test
   @TestProjectInput(createEmptyCommit = false)
   public void listBranchesOfEmptyProject() throws Exception {
-    assertBranches(ImmutableList.of(
+    assertRefs(ImmutableList.of(
           branch("HEAD", null, false),
           branch(RefNames.REFS_CONFIG,  null, false)),
         list().get());
@@ -57,7 +57,7 @@
   public void listBranches() throws Exception {
     String master = pushTo("refs/heads/master").getCommit().name();
     String dev = pushTo("refs/heads/dev").getCommit().name();
-    assertBranches(ImmutableList.of(
+    assertRefs(ImmutableList.of(
           branch("HEAD", "master", false),
           branch(RefNames.REFS_CONFIG,  null, false),
           branch("refs/heads/dev", dev, true),
@@ -72,7 +72,7 @@
     pushTo("refs/heads/dev");
     setApiUser(user);
     // refs/meta/config is hidden since user is no project owner
-    assertBranches(ImmutableList.of(
+    assertRefs(ImmutableList.of(
           branch("HEAD", "master", false),
           branch("refs/heads/master", master, false)),
         list().get());
@@ -85,7 +85,7 @@
     String dev = pushTo("refs/heads/dev").getCommit().name();
     setApiUser(user);
     // refs/meta/config is hidden since user is no project owner
-    assertBranches(ImmutableList.of(branch("refs/heads/dev", dev, false)),
+    assertRefs(ImmutableList.of(branch("refs/heads/dev", dev, false)),
         list().get());
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BranchAssert.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/RefAssert.java
similarity index 69%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BranchAssert.java
rename to gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/RefAssert.java
index 522836d..0cbf79a 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BranchAssert.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/RefAssert.java
@@ -17,26 +17,26 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.Iterables;
-import com.google.gerrit.extensions.api.projects.BranchInfo;
+import com.google.gerrit.extensions.api.projects.RefInfo;
 
 import java.util.List;
 
-public class BranchAssert {
-  public static void assertBranches(List<BranchInfo> expectedBranches,
-      List<BranchInfo> actualBranches) {
-    assertRefNames(refs(expectedBranches), actualBranches);
-    for (int i = 0; i < expectedBranches.size(); i++) {
-      assertBranchInfo(expectedBranches.get(i), actualBranches.get(i));
+public class RefAssert {
+  public static void assertRefs(List<? extends RefInfo> expectedRefs,
+      List<? extends RefInfo> actualRefs) {
+    assertRefNames(refs(expectedRefs), actualRefs);
+    for (int i = 0; i < expectedRefs.size(); i++) {
+      assertRefInfo(expectedRefs.get(i), actualRefs.get(i));
     }
   }
 
   public static void assertRefNames(Iterable<String> expectedRefs,
-      Iterable<BranchInfo> actualBranches) {
-    Iterable<String> actualNames = refs(actualBranches);
+      Iterable<? extends RefInfo> actualRefs) {
+    Iterable<String> actualNames = refs(actualRefs);
     assertThat(actualNames).containsExactlyElementsIn(expectedRefs).inOrder();
   }
 
-  public static void assertBranchInfo(BranchInfo expected, BranchInfo actual) {
+  public static void assertRefInfo(RefInfo expected, RefInfo actual) {
     assertThat(actual.ref).isEqualTo(expected.ref);
     if (expected.revision != null) {
       assertThat(actual.revision).named("revision of " + actual.ref)
@@ -46,7 +46,7 @@
         .isEqualTo(toBoolean(expected.canDelete));
   }
 
-  private static Iterable<String> refs(Iterable<BranchInfo> infos) {
+  private static Iterable<String> refs(Iterable<? extends RefInfo> infos) {
     return Iterables.transform(infos, b -> b.ref);
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index 37ced5f..a39f300 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.common.RawInputUtil;
@@ -648,6 +649,7 @@
   }
 
   @Test
+  @GerritConfig(name = "index.testReindexAfterUpdate", value = "false")
   public void getRelatedForStaleChange() throws Exception {
     RevCommit c1_1 = commitBuilder()
         .add("a.txt", "1")
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailIT.java
index 66a7f15..a5f38dc 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailIT.java
@@ -38,10 +38,10 @@
 @NoHttpd
 @RunWith(ConfigSuite.class)
 public class MailIT extends AbstractDaemonTest {
-  private final static String RECEIVEEMAIL = "receiveemail";
-  private final static String HOST = "localhost";
-  private final static String USERNAME = "user@domain.com";
-  private final static String PASSWORD = "password";
+  private static final String RECEIVEEMAIL = "receiveemail";
+  private static final String HOST = "localhost";
+  private static final String USERNAME = "user@domain.com";
+  private static final String PASSWORD = "password";
 
   @Inject
   private MailReceiver mailReceiver;
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
new file mode 100644
index 0000000..7d59fb1
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
@@ -0,0 +1,165 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.mail;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.server.mail.MailUtil;
+import com.google.gerrit.server.mail.send.EmailHeader;
+import com.google.gerrit.testutil.FakeEmailSender;
+import com.google.gerrit.testutil.TestTimeUtil;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.sql.Timestamp;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/** Tests the presence of required metadata in email headers, text and html. */
+public class MailMetadataIT extends AbstractDaemonTest {
+  private String systemTimeZone;
+
+  @Before
+  public void setTimeForTesting() {
+    systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+  }
+
+  @After
+  public void resetTime() {
+    TestTimeUtil.useSystemTime();
+    System.setProperty("user.timezone", systemTimeZone);
+  }
+
+  @Test
+  public void metadataOnNewChange() throws Exception {
+    PushOneCommit.Result newChange = createChange();
+    gApi.changes()
+        .id(newChange.getChangeId())
+        .addReviewer(user.getId().toString());
+
+    List<FakeEmailSender.Message> emails = sender.getMessages();
+    assertThat(emails).hasSize(1);
+    FakeEmailSender.Message message = emails.get(0);
+
+    String changeURL = "<" + canonicalWebUrl.get() +
+        newChange.getChange().getId().get() + ">";
+
+    Map<String, Object> expectedHeaders = new HashMap<>();
+    expectedHeaders.put("Gerrit-PatchSet", "1");
+    expectedHeaders.put("Gerrit-Change-Id", newChange.getChangeId());
+    expectedHeaders.put("Gerrit-MessageType", "newchange");
+    expectedHeaders.put("Gerrit-Commit", newChange.getCommit().getId().name());
+    expectedHeaders.put("Gerrit-ChangeURL", changeURL);
+
+    assertHeaders(message.headers(), expectedHeaders);
+
+    // Remove metadata that is not present in email
+    expectedHeaders.remove("Gerrit-ChangeURL");
+    expectedHeaders.remove("Gerrit-Commit");
+    assertTextFooter(message.body(), expectedHeaders);
+  }
+
+  @Test
+  public void metadataOnNewComment() throws Exception {
+    PushOneCommit.Result newChange = createChange();
+    gApi.changes()
+        .id(newChange.getChangeId())
+        .addReviewer(user.getId().toString());
+    sender.clear();
+
+    // Review change
+    ReviewInput input = new ReviewInput();
+    input.message = "Test";
+    revision(newChange).review(input);
+    setApiUser(user);
+    Collection<ChangeMessageInfo> result =
+        gApi.changes().id(newChange.getChangeId()).get().messages;
+    assertThat(result).isNotEmpty();
+
+    List<FakeEmailSender.Message> emails = sender.getMessages();
+    assertThat(emails).hasSize(1);
+    FakeEmailSender.Message message = emails.get(0);
+
+    String changeURL = "<" + canonicalWebUrl.get() +
+        newChange.getChange().getId().get() + ">";
+    Map<String, Object> expectedHeaders = new HashMap<>();
+    expectedHeaders.put("Gerrit-PatchSet", "1");
+    expectedHeaders.put("Gerrit-Change-Id", newChange.getChangeId());
+    expectedHeaders.put("Gerrit-MessageType", "comment");
+    expectedHeaders.put("Gerrit-Commit",
+        newChange.getCommit().getId().name());
+    expectedHeaders.put("Gerrit-ChangeURL", changeURL);
+    expectedHeaders.put("Gerrit-Comment-Date",
+        Iterables.getLast(result).date);
+
+    assertHeaders(message.headers(), expectedHeaders);
+
+    // Remove metadata that is not present in email
+    expectedHeaders.remove("Gerrit-ChangeURL");
+    expectedHeaders.remove("Gerrit-Commit");
+    assertTextFooter(message.body(), expectedHeaders);
+  }
+
+  private static void assertHeaders(Map<String, EmailHeader> have,
+      Map<String, Object> want) throws Exception {
+    for (Map.Entry<String, Object> entry : want.entrySet()) {
+      if (entry.getValue() instanceof String) {
+        assertThat(have).containsEntry("X-" + entry.getKey(),
+            new EmailHeader.String((String) entry.getValue()));
+      } else if (entry.getValue() instanceof Date) {
+        assertThat(have).containsEntry("X-" + entry.getKey(),
+            new EmailHeader.Date((Date) entry.getValue()));
+      } else {
+        throw new Exception("Object has unsupported type: " +
+            entry.getValue().getClass().getName() +
+            " must be java.util.Date or java.lang.String for key " +
+            entry.getKey());
+      }
+    }
+  }
+
+  private static void assertTextFooter(String body,
+      Map<String, Object> want) throws Exception {
+    for (Map.Entry<String, Object> entry : want.entrySet()) {
+      if (entry.getValue() instanceof String) {
+        assertThat(body).contains(entry.getKey() + ": " + entry.getValue());
+      } else if (entry.getValue() instanceof Timestamp) {
+        assertThat(body).contains(entry.getKey() + ": " +
+            MailUtil.rfcDateformatter.format(ZonedDateTime.ofInstant(
+                ((Timestamp) entry.getValue()).toInstant(),
+                ZoneId.of("UTC"))));
+      } else {
+        throw new Exception("Object has unsupported type: " +
+            entry.getValue().getClass().getName() +
+            " must be java.util.Date or java.lang.String for key " +
+            entry.getKey());
+      }
+    }
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
index a2b93cc..7ab1753 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
@@ -110,6 +110,13 @@
   public static Config defaultConfig() {
     Config cfg = new Config();
     cfg.setBoolean("noteDb", null, "testRebuilderWrapper", true);
+
+    // Disable async reindex-if-stale check after index update. This avoids
+    // unintentional auto-rebuilding of the change in NoteDb during the read
+    // path of the reindex-if-stale check. For the purposes of this test, we
+    // want precise control over when auto-rebuilding happens.
+    cfg.setBoolean("index", null, "testReindexAfterUpdate", false);
+
     return cfg;
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/CustomLabelIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
index 6f4cc45..8b1690f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
@@ -26,11 +26,13 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.group.SystemGroupBackend;
@@ -175,6 +177,34 @@
     assertThat(q.blocking).isTrue();
   }
 
+  @Test
+  public void customLabel_DisallowPostSubmit() throws Exception {
+    label.setFunctionName("NoOp");
+    label.setAllowPostSubmit(false);
+    P.setFunctionName("NoOp");
+    saveLabelConfig();
+
+    PushOneCommit.Result r = createChange();
+    revision(r).review(ReviewInput.approve());
+    revision(r).submit();
+
+    ChangeInfo info = get(r.getChangeId(), ListChangesOption.DETAILED_LABELS);
+    assertPermitted(info, "Code-Review", 2);
+    assertPermitted(info, P.getName(), 0, 1);
+    assertPermitted(info, label.getName());
+
+    ReviewInput in = new ReviewInput();
+    in.label(P.getName(), P.getMax().getValue());
+    revision(r).review(in);
+
+    in = new ReviewInput();
+    in.label(label.getName(), label.getMax().getValue());
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(
+        "Voting on labels disallowed after submit: " + label.getName());
+    revision(r).review(in);
+  }
+
   private void saveLabelConfig() throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     cfg.getLabelSections().put(label.getName(), label);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index c999c9c..e6efc0a 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -19,6 +19,8 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
@@ -37,6 +39,7 @@
 import java.util.List;
 
 @NoHttpd
+@Sandboxed
 public class ProjectWatchIT extends AbstractDaemonTest {
   @Test
   public void newPatchSetsNotifyConfig() throws Exception {
@@ -111,12 +114,18 @@
 
   @Test
   public void watchFile() throws Exception {
-    // watch file in project
     String watchedProject = createProject("watchedProject").get();
+    String otherWatchedProject = createProject("otherWatchedProject").get();
     setApiUser(user);
+
+    // watch file in project as user
     watch(watchedProject, "file:a.txt");
 
-    // push a change to watched file -> should trigger email notification
+    // watch other project as user
+    watch(otherWatchedProject, null);
+
+    // push a change to watched file -> should trigger email notification for
+    // user
     setApiUser(admin);
     TestRepository<InMemoryRepository> watchedRepo =
         cloneProject(new Project.NameKey(watchedProject), admin);
@@ -125,10 +134,87 @@
         .to("refs/for/master");
     r.assertOkStatus();
 
+    // assert email notification for user
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body()).contains("Change subject: TRIGGER\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+    sender.clear();
+
+    // watch project as user2
+    TestAccount user2 = accounts.create("user2", "user2@test.com", "User2");
+    setApiUser(user2);
+    watch(watchedProject, null);
+
     // push a change to non-watched file -> should not trigger email
-    // notification
-    r = pushFactory.create(db, admin.getIdent(), testRepo,
-        "DONT_TRIGGER", "b.txt", "b1").to("refs/for/master");
+    // notification for user, only for user2
+    r = pushFactory.create(db, admin.getIdent(), watchedRepo,
+        "TRIGGER_USER2", "b.txt", "b1").to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification
+    messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user2.emailAddress);
+    assertThat(m.body()).contains("Change subject: TRIGGER_USER2\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+  }
+
+  @Test
+  public void watchKeyword() throws Exception {
+    String watchedProject = createProject("watchedProject").get();
+    setApiUser(user);
+
+    // watch keyword in project as user
+    watch(watchedProject, "multimaster");
+
+    // push a change with keyword -> should trigger email notification
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(new Project.NameKey(watchedProject), admin);
+    PushOneCommit.Result r = pushFactory
+        .create(db, admin.getIdent(), watchedRepo,
+            "Document multimaster setup", "a.txt", "a1")
+        .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification for user
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body())
+        .contains("Change subject: Document multimaster setup\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+    sender.clear();
+
+    // push a change without keyword -> should not trigger email notification
+    r = pushFactory.create(db, admin.getIdent(), watchedRepo,
+        "Cleanup cache implementation", "b.txt", "b1").to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification
+    assertThat(sender.getMessages()).hasSize(0);
+  }
+
+  @Test
+  public void watchAllProjects() throws Exception {
+    String anyProject = createProject("anyProject").get();
+    setApiUser(user);
+
+    // watch the All-Projects project to watch all projects
+    watch(allProjects.get(), null);
+
+    // push a change to any project -> should trigger email notification
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> anyRepo =
+        cloneProject(new Project.NameKey(anyProject), admin);
+    PushOneCommit.Result r = pushFactory
+        .create(db, admin.getIdent(), anyRepo, "TRIGGER", "a", "a1")
+        .to("refs/for/master");
     r.assertOkStatus();
 
     // assert email notification
@@ -140,6 +226,93 @@
     assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
   }
 
+  @Test
+  public void watchFileAllProjects() throws Exception {
+    String anyProject = createProject("anyProject").get();
+    setApiUser(user);
+
+    // watch file in All-Projects project as user to watch the file in all
+    // projects
+    watch(allProjects.get(), "file:a.txt");
+
+    // push a change to watched file in any project -> should trigger email
+    // notification for user
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> anyRepo =
+        cloneProject(new Project.NameKey(anyProject), admin);
+    PushOneCommit.Result r = pushFactory
+        .create(db, admin.getIdent(), anyRepo, "TRIGGER", "a.txt", "a1")
+        .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification for user
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body()).contains("Change subject: TRIGGER\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+    sender.clear();
+
+    // watch project as user2
+    TestAccount user2 = accounts.create("user2", "user2@test.com", "User2");
+    setApiUser(user2);
+    watch(anyProject, null);
+
+    // push a change to non-watched file in any project -> should not trigger
+    // email notification for user, only for user2
+    r = pushFactory.create(db, admin.getIdent(), anyRepo,
+        "TRIGGER_USER2", "b.txt", "b1").to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification
+    messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user2.emailAddress);
+    assertThat(m.body()).contains("Change subject: TRIGGER_USER2\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+  }
+
+  @Test
+  public void watchKeywordAllProjects() throws Exception {
+    String anyProject = createProject("anyProject").get();
+    setApiUser(user);
+
+    // watch keyword in project as user
+    watch(allProjects.get(), "multimaster");
+
+    // push a change with keyword to any project -> should trigger email
+    // notification
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> anyRepo =
+        cloneProject(new Project.NameKey(anyProject), admin);
+    PushOneCommit.Result r = pushFactory
+        .create(db, admin.getIdent(), anyRepo,
+            "Document multimaster setup", "a.txt", "a1")
+        .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification for user
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body())
+        .contains("Change subject: Document multimaster setup\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+    sender.clear();
+
+    // push a change without keyword to any project -> should not trigger email
+    // notification
+    r = pushFactory.create(db, admin.getIdent(), anyRepo,
+        "Cleanup cache implementation", "b.txt", "b1").to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification
+    assertThat(sender.getMessages()).hasSize(0);
+  }
+
   private void watch(String project, String filter)
       throws RestApiException {
     List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
diff --git a/gerrit-common/BUILD b/gerrit-common/BUILD
index 72bd71b..9add6e7 100644
--- a/gerrit-common/BUILD
+++ b/gerrit-common/BUILD
@@ -68,13 +68,11 @@
   ],
 )
 
-# TODO(davido): Enable this test again when this bazel bug is fixed:
-# https://github.com/bazelbuild/bazel/issues/2044
-#junit_tests(
-#  name = 'auto_value_tests',
-#  srcs = AUTO_VALUE_TEST_SRCS,
-#  deps = [
-#    '//lib:truth',
-#    '//lib/auto:auto-value',
-#  ],
-#)
+junit_tests(
+  name = 'auto_value_tests',
+  srcs = AUTO_VALUE_TEST_SRCS,
+  deps = [
+    '//lib:truth',
+    '//lib/auto:auto-value',
+  ],
+)
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelType.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelType.java
index b1e1243..7a8ac77 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelType.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelType.java
@@ -25,6 +25,7 @@
 import java.util.Map;
 
 public class LabelType {
+  public static final boolean DEF_ALLOW_POST_SUBMIT = true;
   public static final boolean DEF_CAN_OVERRIDE = true;
   public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CHANGE = true;
   public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = false;
@@ -104,6 +105,7 @@
   protected boolean copyAllScoresOnTrivialRebase;
   protected boolean copyAllScoresIfNoCodeChange;
   protected boolean copyAllScoresIfNoChange;
+  protected boolean allowPostSubmit;
   protected short defaultValue;
 
   protected List<LabelValue> values;
@@ -144,6 +146,7 @@
         DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
     setCopyMaxScore(DEF_COPY_MAX_SCORE);
     setCopyMinScore(DEF_COPY_MIN_SCORE);
+    setAllowPostSubmit(DEF_ALLOW_POST_SUBMIT);
   }
 
   public String getName() {
@@ -174,6 +177,14 @@
     this.canOverride = canOverride;
   }
 
+  public boolean allowPostSubmit() {
+    return allowPostSubmit;
+  }
+
+  public void setAllowPostSubmit(boolean allowPostSubmit) {
+    this.allowPostSubmit = allowPostSubmit;
+  }
+
   public void setRefPatterns(List<String> refPatterns) {
     this.refPatterns = refPatterns;
   }
@@ -193,8 +204,7 @@
     if (values.isEmpty()) {
       return null;
     }
-    final LabelValue v = values.get(values.size() - 1);
-    return v.getValue() > 0 ? v : null;
+    return values.get(values.size() - 1);
   }
 
   public short getDefaultValue() {
diff --git a/gerrit-elasticsearch/BUCK b/gerrit-elasticsearch/BUCK
index a16ad50..f2dc3de 100644
--- a/gerrit-elasticsearch/BUCK
+++ b/gerrit-elasticsearch/BUCK
@@ -4,11 +4,9 @@
   deps = [
     '//gerrit-antlr:query_exception',
     '//gerrit-extension-api:api',
-    '//gerrit-lucene:lucene', # only for LuceneAccountIndex
     '//gerrit-reviewdb:client',
     '//gerrit-reviewdb:server',
     '//gerrit-server:server',
-    '//gerrit-index:index',
     '//lib:gson',
     '//lib:guava',
     '//lib:gwtorm',
@@ -36,6 +34,7 @@
   deps = [
     ':elasticsearch',
     '//gerrit-extension-api:api',
+    '//gerrit-reviewdb:server',
     '//gerrit-server:server',
     '//gerrit-server:testutil',
     '//gerrit-server:query_tests',
diff --git a/gerrit-elasticsearch/BUILD b/gerrit-elasticsearch/BUILD
index 81009d1..3510747 100644
--- a/gerrit-elasticsearch/BUILD
+++ b/gerrit-elasticsearch/BUILD
@@ -4,11 +4,9 @@
   deps = [
     '//gerrit-antlr:query_exception',
     '//gerrit-extension-api:api',
-    '//gerrit-lucene:lucene', # only for LuceneAccountIndex
     '//gerrit-reviewdb:client',
     '//gerrit-reviewdb:server',
     '//gerrit-server:server',
-    '//gerrit-index:index',
     '//lib:gson',
     '//lib:guava',
     '//lib:gwtorm',
@@ -31,22 +29,34 @@
 
 load('//tools/bzl:junit.bzl', 'junit_tests')
 
-junit_tests(
-  name = 'elasticsearch_tests',
-  tags = ['elastic', 'flaky'],
-  srcs = glob(['src/test/java/**/*.java']),
-  size = "large",
+java_library(
+  name = 'elasticsearch_test_utils',
+  srcs = glob(['src/test/java/**/ElasticTestUtils.java']),
   deps = [
-    ':elasticsearch',
     '//gerrit-extension-api:api',
     '//gerrit-server:server',
-    '//gerrit-server:testutil',
-    '//gerrit-server:query_tests_code',
     '//lib:gson',
     '//lib:guava',
     '//lib:junit',
     '//lib:truth',
     '//lib/elasticsearch:elasticsearch',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/jgit/org.eclipse.jgit.junit:junit',
+  ],
+  testonly = 1,
+)
+
+junit_tests(
+  name = 'elasticsearch_tests',
+  tags = ['elastic', 'flaky'],
+  srcs = glob(['src/test/java/**/*Test.java']),
+  size = "large",
+  deps = [
+    ':elasticsearch_test_utils',
+    ':elasticsearch',
+    '//gerrit-server:query_tests_code',
+    '//gerrit-server:server',
+    '//gerrit-server:testutil',
     '//lib/guice:guice',
     '//lib/jgit/org.eclipse.jgit:jgit',
     '//lib/jgit/org.eclipse.jgit.junit:junit',
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
index a46edc7..917238b 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
@@ -14,21 +14,23 @@
 
 package com.google.gerrit.elasticsearch;
 
-import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkState;
+import static org.apache.commons.codec.binary.Base64.decodeBase64;
 import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.FluentIterable;
 import com.google.common.collect.Iterables;
-import com.google.gerrit.index.IndexUtils;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.FieldDef.FillArgs;
 import com.google.gerrit.server.index.Index;
+import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.Schema;
 import com.google.gerrit.server.index.Schema.Values;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.gwtorm.protobuf.ProtobufCodec;
 
 import org.eclipse.jgit.lib.Config;
 import org.elasticsearch.common.xcontent.XContentBuilder;
@@ -36,6 +38,7 @@
 import java.io.IOException;
 import java.net.MalformedURLException;
 import java.net.URL;
+import java.util.List;
 import java.util.concurrent.TimeUnit;
 
 import io.searchbox.client.JestClientFactory;
@@ -49,7 +52,16 @@
 import io.searchbox.indices.IndicesExists;
 
 abstract class AbstractElasticIndex<K, V> implements Index<K, V> {
-  private static final String DEFAULT_INDEX_NAME = "gerrit";
+  protected static <T> List<T> decodeProtos(JsonObject doc, String fieldName,
+      ProtobufCodec<T> codec) {
+    JsonArray field = doc.getAsJsonArray(fieldName);
+    if (field == null) {
+      return null;
+    }
+    return FluentIterable.from(field)
+        .transform(i -> codec.decode(decodeBase64(i.toString())))
+        .toList();
+  }
 
   private final Schema<V> schema;
   private final FillArgs fillArgs;
@@ -59,12 +71,11 @@
   protected final String indexName;
   protected final JestHttpClient client;
 
-
-  @Inject
   AbstractElasticIndex(@GerritServerConfig Config cfg,
       FillArgs fillArgs,
       SitePaths sitePaths,
-      @Assisted Schema<V> schema) {
+      Schema<V> schema,
+      String indexName) {
     this.fillArgs = fillArgs;
     this.sitePaths = sitePaths;
     this.schema = schema;
@@ -72,8 +83,10 @@
     String hostname = getRequiredConfigOption(cfg, "hostname");
     String port = getRequiredConfigOption(cfg, "port");
 
-    this.indexName =
-        firstNonNull(cfg.getString("index", null, "name"), DEFAULT_INDEX_NAME);
+    this.indexName = String.format("%s%s%04d",
+        Strings.nullToEmpty(cfg.getString("index", null, "prefix")),
+        indexName,
+        schema.getVersion());
 
     // By default Elasticsearch has a 1s delay before changes are available in
     // the index.  Setting refresh(true) on calls to the index makes the index
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
new file mode 100644
index 0000000..8b0ea41
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
@@ -0,0 +1,234 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import static com.google.gerrit.server.index.account.AccountField.ID;
+import static com.google.gson.FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Account.Id;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.FieldDef.FillArgs;
+import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.index.account.AccountField;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.query.DataSource;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+
+import org.eclipse.jgit.lib.Config;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.search.builder.SearchSourceBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+
+import io.searchbox.client.JestResult;
+import io.searchbox.core.Bulk;
+import io.searchbox.core.Bulk.Builder;
+import io.searchbox.core.Search;
+import io.searchbox.core.search.sort.Sort;
+import io.searchbox.core.search.sort.Sort.Sorting;
+
+class ElasticAccountIndex extends AbstractElasticIndex<Account.Id, AccountState>
+    implements AccountIndex {
+  static class AccountMapping {
+    MappingProperties accounts;
+
+    AccountMapping(Schema<AccountState> schema) {
+      this.accounts = ElasticMapping.createMapping(schema);
+    }
+  }
+
+  static final String ACCOUNTS = "accounts";
+  static final String ACCOUNTS_PREFIX = ACCOUNTS + "_";
+
+  private static final Logger log =
+      LoggerFactory.getLogger(ElasticAccountIndex.class);
+
+  private final Gson gson;
+  private final AccountMapping mapping;
+  private final AccountCache accountCache;
+  private final ElasticQueryBuilder queryBuilder;
+
+  @AssistedInject
+  ElasticAccountIndex(
+      @GerritServerConfig Config cfg,
+      FillArgs fillArgs,
+      SitePaths sitePaths,
+      AccountCache accountCache,
+      @Assisted Schema<AccountState> schema) {
+    super(cfg, fillArgs, sitePaths, schema, ACCOUNTS_PREFIX);
+    this.accountCache = accountCache;
+    this.mapping = new AccountMapping(schema);
+    this.queryBuilder = new ElasticQueryBuilder();
+    this.gson = new GsonBuilder()
+        .setFieldNamingPolicy(LOWER_CASE_WITH_UNDERSCORES).create();
+  }
+
+  @Override
+  public void replace(AccountState as) throws IOException {
+    Bulk bulk = new Bulk.Builder()
+        .defaultIndex(indexName)
+        .defaultType(ACCOUNTS)
+        .addAction(insert(ACCOUNTS, as))
+        .refresh(refresh)
+        .build();
+      JestResult result = client.execute(bulk);
+    if (!result.isSucceeded()) {
+      throw new IOException(
+          String.format("Failed to replace account %s in index %s: %s",
+              as.getAccount().getId(), indexName, result.getErrorMessage()));
+    }
+  }
+
+  @Override
+  public DataSource<AccountState> getSource(Predicate<AccountState> p,
+      QueryOptions opts) throws QueryParseException {
+    return new QuerySource(p, opts);
+  }
+
+  @Override
+  protected Builder addActions(Builder builder, Id c) {
+    return builder.addAction(delete(ACCOUNTS, c));
+  }
+
+  @Override
+  protected String getMappings() {
+    ImmutableMap<String, AccountMapping> mappings =
+        ImmutableMap.of("mappings", mapping);
+    return gson.toJson(mappings);
+  }
+
+  @Override
+  protected String getId(AccountState as) {
+    return as.getAccount().getId().toString();
+  }
+
+  private class QuerySource implements DataSource<AccountState> {
+    private final Search search;
+    private final Set<String> fields;
+
+    QuerySource(Predicate<AccountState> p, QueryOptions opts)
+        throws QueryParseException {
+      QueryBuilder qb = queryBuilder.toQueryBuilder(p);
+      fields = IndexUtils.accountFields(opts);
+      SearchSourceBuilder searchSource = new SearchSourceBuilder()
+          .query(qb)
+          .from(opts.start())
+          .size(opts.limit())
+          .fields(Lists.newArrayList(fields));
+
+      Sort sort = new Sort(AccountField.ID.getName(), Sorting.ASC);
+      sort.setIgnoreUnmapped();
+
+      search = new Search.Builder(searchSource.toString())
+          .addType(ACCOUNTS)
+          .addIndex(indexName)
+          .addSort(ImmutableList.of(sort))
+          .build();
+    }
+
+    @Override
+    public int getCardinality() {
+      return 10;
+    }
+
+    @Override
+    public ResultSet<AccountState> read() throws OrmException {
+      try {
+        List<AccountState> results = Collections.emptyList();
+        JestResult result = client.execute(search);
+        if (result.isSucceeded()) {
+          JsonObject obj = result.getJsonObject().getAsJsonObject("hits");
+          if (obj.get("hits") != null) {
+            JsonArray json = obj.getAsJsonArray("hits");
+            results = Lists.newArrayListWithCapacity(json.size());
+            for (int i = 0; i < json.size(); i++) {
+              results.add(toChangeData(json.get(i)));
+            }
+          }
+        } else {
+          log.error(result.getErrorMessage());
+        }
+        final List<AccountState> r = Collections.unmodifiableList(results);
+        return new ResultSet<AccountState>() {
+          @Override
+          public Iterator<AccountState> iterator() {
+            return r.iterator();
+          }
+
+          @Override
+          public List<AccountState> toList() {
+            return r;
+          }
+
+          @Override
+          public void close() {
+            // Do nothing.
+          }
+        };
+      } catch (IOException e) {
+        throw new OrmException(e);
+      }
+    }
+
+    @Override
+    public String toString() {
+      return search.toString();
+    }
+
+    private AccountState toChangeData(JsonElement json) {
+      JsonElement source = json.getAsJsonObject().get("_source");
+      if (source == null) {
+        source = json.getAsJsonObject().get("fields");
+      }
+      return toAccountState(source);
+    }
+
+    private AccountState toAccountState(JsonElement element) {
+      Account.Id id = new Account.Id(
+          element.getAsJsonObject().get(ID.getName()).getAsInt());
+      // Use the AccountCache rather than depending on any stored fields in the
+      // document (of which there shouldn't be any. The most expensive part to
+      // compute anyway is the effective group IDs, and we don't have a good way
+      // to reindex when those change.
+      return accountCache.get(id);
+    }
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
index dd272fa..13d9ad6 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -23,10 +23,10 @@
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
-import com.google.gerrit.index.IndexUtils;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Id;
@@ -35,9 +35,8 @@
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.FieldDef;
 import com.google.gerrit.server.index.FieldDef.FillArgs;
-import com.google.gerrit.server.index.FieldType;
+import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.QueryOptions;
 import com.google.gerrit.server.index.Schema;
 import com.google.gerrit.server.index.change.ChangeField;
@@ -56,7 +55,6 @@
 import com.google.gson.JsonArray;
 import com.google.gson.JsonElement;
 import com.google.gson.JsonObject;
-import com.google.gwtorm.protobuf.ProtobufCodec;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Provider;
@@ -94,33 +92,13 @@
     MappingProperties closedChanges;
 
     ChangeMapping(Schema<ChangeData> schema) {
-      ElasticMapping.Builder mappingBuilder = new ElasticMapping.Builder();
-      for (FieldDef<?, ?> field : schema.getFields().values()) {
-        String name = field.getName();
-        FieldType<?> fieldType = field.getType();
-        if (fieldType == FieldType.EXACT) {
-          mappingBuilder.addExactField(name);
-        } else if (fieldType == FieldType.TIMESTAMP) {
-          mappingBuilder.addTimestamp(name);
-        } else if (fieldType == FieldType.INTEGER
-            || fieldType == FieldType.INTEGER_RANGE
-            || fieldType == FieldType.LONG) {
-          mappingBuilder.addNumber(name);
-        } else if (fieldType == FieldType.PREFIX
-            || fieldType == FieldType.FULL_TEXT
-            || fieldType == FieldType.STORED_ONLY) {
-          mappingBuilder.addString(name);
-        } else {
-          throw new IllegalArgumentException(
-              "Unsupported filed type " + fieldType.getName());
-        }
-      }
-      MappingProperties mapping = mappingBuilder.build();
-      openChanges = mapping;
-      closedChanges = mapping;
+      MappingProperties mapping = ElasticMapping.createMapping(schema);
+      this.openChanges = mapping;
+      this.closedChanges = mapping;
     }
   }
 
+  static final String CHANGES_PREFIX = "changes_";
   static final String OPEN_CHANGES = "open_changes";
   static final String CLOSED_CHANGES = "closed_changes";
 
@@ -138,7 +116,7 @@
       FillArgs fillArgs,
       SitePaths sitePaths,
       @Assisted Schema<ChangeData> schema) {
-    super(cfg, fillArgs, sitePaths, schema);
+    super(cfg, fillArgs, sitePaths, schema, CHANGES_PREFIX);
     this.db = db;
     this.changeDataFactory = changeDataFactory;
     mapping = new ChangeMapping(schema);
@@ -148,13 +126,6 @@
         .setFieldNamingPolicy(LOWER_CASE_WITH_UNDERSCORES).create();
   }
 
-  private static <T> List<T> decodeProtos(JsonObject doc, String fieldName,
-      ProtobufCodec<T> codec) {
-    return FluentIterable.from(doc.getAsJsonArray(fieldName))
-        .transform(i -> codec.decode(decodeBase64(i.toString())))
-        .toList();
-  }
-
   @Override
   public void replace(ChangeData cd) throws IOException {
     String deleteIndex;
@@ -231,7 +202,7 @@
         sort.setIgnoreUnmapped();
       }
       QueryBuilder qb = queryBuilder.toQueryBuilder(p);
-      fields = IndexUtils.fields(opts);
+      fields = IndexUtils.changeFields(opts);
       SearchSourceBuilder searchSource = new SearchSourceBuilder()
           .query(qb)
           .from(opts.start())
@@ -393,6 +364,21 @@
           ChangeField.STORED_SUBMIT_RECORD_LENIENT.getName(),
           ChangeField.SUBMIT_RULE_OPTIONS_LENIENT, cd);
 
+      if (source.get(ChangeField.REF_STATE.getName()) != null) {
+        JsonArray refStates =
+            source.get(ChangeField.REF_STATE.getName()).getAsJsonArray();
+        cd.setRefStates(
+            Iterables.transform(
+                refStates, e -> Base64.decodeBase64(e.getAsString())));
+      }
+      if (source.get(ChangeField.REF_STATE_PATTERN.getName()) != null) {
+        JsonArray refStatePatterns = source.get(
+            ChangeField.REF_STATE_PATTERN.getName()).getAsJsonArray();
+        cd.setRefStatePatterns(
+            Iterables.transform(
+                refStatePatterns, e -> Base64.decodeBase64(e.getAsString())));
+      }
+
       return cd;
     }
 
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
index e108dca..3762368 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
@@ -14,12 +14,11 @@
 
 package com.google.gerrit.elasticsearch;
 
-import com.google.gerrit.index.SingleVersionModule;
 import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.lucene.LuceneAccountIndex;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.IndexConfig;
 import com.google.gerrit.server.index.IndexModule;
+import com.google.gerrit.server.index.SingleVersionModule;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.inject.Provides;
@@ -56,9 +55,7 @@
             .build(ChangeIndex.Factory.class));
     install(
         new FactoryModuleBuilder()
-            // until we implement Elasticsearch index for accounts we need to
-            // use Lucene to make all tests green and Gerrit server to work
-            .implement(AccountIndex.class, LuceneAccountIndex.class)
+            .implement(AccountIndex.class, ElasticAccountIndex.class)
             .build(AccountIndex.Factory.class));
 
     install(new IndexModule(threads));
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticMapping.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticMapping.java
index e3f7e96..45f686f 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticMapping.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticMapping.java
@@ -15,10 +15,38 @@
 package com.google.gerrit.elasticsearch;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.server.index.FieldDef;
+import com.google.gerrit.server.index.FieldType;
+import com.google.gerrit.server.index.Schema;
 
 import java.util.Map;
 
 class ElasticMapping {
+  static MappingProperties createMapping(Schema<?> schema) {
+    ElasticMapping.Builder mapping = new ElasticMapping.Builder();
+    for (FieldDef<?, ?> field : schema.getFields().values()) {
+      String name = field.getName();
+      FieldType<?> fieldType = field.getType();
+      if (fieldType == FieldType.EXACT) {
+        mapping.addExactField(name);
+      } else if (fieldType == FieldType.TIMESTAMP) {
+        mapping.addTimestamp(name);
+      } else if (fieldType == FieldType.INTEGER
+          || fieldType == FieldType.INTEGER_RANGE
+          || fieldType == FieldType.LONG) {
+        mapping.addNumber(name);
+      } else if (fieldType == FieldType.PREFIX
+          || fieldType == FieldType.FULL_TEXT
+          || fieldType == FieldType.STORED_ONLY) {
+        mapping.addString(name);
+      } else {
+        throw new IllegalStateException(
+            "Unsupported field type: " + fieldType.getName());
+      }
+    }
+    return mapping.build();
+  }
+
   static class Builder {
     private final ImmutableMap.Builder<String, FieldProperties> fields =
         new ImmutableMap.Builder<>();
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
index 51b14a4..22f3d76 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
@@ -31,7 +31,8 @@
 import org.elasticsearch.index.query.BoolQueryBuilder;
 import org.elasticsearch.index.query.QueryBuilder;
 import org.elasticsearch.index.query.QueryBuilders;
-import org.joda.time.DateTime;
+
+import java.time.Instant;
 
 public class ElasticQueryBuilder {
 
@@ -138,7 +139,7 @@
       throws QueryParseException {
     if (r.getMinTimestamp().getTime() == 0) {
       return QueryBuilders.rangeQuery(r.getField().getName())
-          .gt(new DateTime(r.getMaxTimestamp().getTime()));
+          .gt(Instant.ofEpochMilli(r.getMaxTimestamp().getTime()));
     }
     throw new QueryParseException("cannot negate: " + r);
   }
@@ -150,11 +151,11 @@
           (TimestampRangePredicate<T>) p;
       if (p instanceof AfterPredicate) {
         return QueryBuilders.rangeQuery(r.getField().getName())
-            .gte(new DateTime(r.getMinTimestamp().getTime()));
+            .gte(Instant.ofEpochMilli(r.getMinTimestamp().getTime()));
       }
       return QueryBuilders.rangeQuery(r.getField().getName())
-          .gte(new DateTime(r.getMinTimestamp().getTime()))
-          .lte(new DateTime(r.getMaxTimestamp().getTime()));
+          .gte(Instant.ofEpochMilli(r.getMinTimestamp().getTime()))
+          .lte(Instant.ofEpochMilli(r.getMaxTimestamp().getTime()));
     }
     throw new QueryParseException("not a timestamp: " + p);
   }
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java
new file mode 100644
index 0000000..62c1a57
--- /dev/null
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import static com.google.gerrit.elasticsearch.ElasticAccountIndex.ACCOUNTS_PREFIX;
+
+import com.google.gerrit.elasticsearch.ElasticAccountIndex.AccountMapping;
+import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
+import com.google.gerrit.server.query.account.AbstractQueryAccountsTest;
+import com.google.gerrit.testutil.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+
+import org.eclipse.jgit.lib.Config;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+import java.util.concurrent.ExecutionException;
+
+public class ElasticQueryAccountsTest extends AbstractQueryAccountsTest {
+  private static final String INDEX_NAME =
+      String.format("%s%04d", ACCOUNTS_PREFIX,
+          AccountSchemaDefinitions.INSTANCE.getLatest().getVersion());
+  private static ElasticNodeInfo nodeInfo;
+
+  @BeforeClass
+  public static void startIndexService()
+      throws InterruptedException, ExecutionException {
+    if (nodeInfo != null) {
+      // do not start Elasticsearch twice
+      return;
+    }
+    nodeInfo = ElasticTestUtils.startElasticsearchNode();
+    createIndexes();
+  }
+
+  private static void createIndexes() {
+    AccountMapping accountMapping =
+        new AccountMapping(AccountSchemaDefinitions.INSTANCE.getLatest());
+    nodeInfo.node
+        .client()
+        .admin()
+        .indices()
+        .prepareCreate(INDEX_NAME)
+        .addMapping(ElasticAccountIndex.ACCOUNTS,
+            ElasticTestUtils.gson.toJson(accountMapping))
+        .execute()
+        .actionGet();
+  }
+
+  @AfterClass
+  public static void stopElasticsearchServer() {
+    if (nodeInfo != null) {
+      nodeInfo.node.close();
+      nodeInfo.elasticDir.delete();
+      nodeInfo = null;
+    }
+  }
+
+  @After
+  public void cleanupIndex() {
+    if (nodeInfo != null) {
+      ElasticTestUtils.deleteIndexes(nodeInfo.node, INDEX_NAME);
+      createIndexes();
+    }
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config elasticsearchConfig = new Config(config);
+    InMemoryModule.setDefaults(elasticsearchConfig);
+    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port);
+    return Guice.createInjector(
+        new InMemoryModule(elasticsearchConfig, notesMigration));
+  }
+}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java
index ed96a67..4b76e4b 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java
@@ -14,116 +14,60 @@
 
 package com.google.gerrit.elasticsearch;
 
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.elasticsearch.ElasticChangeIndex.CHANGES_PREFIX;
 import static com.google.gerrit.elasticsearch.ElasticChangeIndex.CLOSED_CHANGES;
 import static com.google.gerrit.elasticsearch.ElasticChangeIndex.OPEN_CHANGES;
 
-import com.google.common.base.Strings;
-import com.google.common.io.Files;
 import com.google.gerrit.elasticsearch.ElasticChangeIndex.ChangeMapping;
-import com.google.gerrit.server.index.IndexModule.IndexType;
+import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
 import com.google.gerrit.testutil.InMemoryModule;
-import com.google.gson.FieldNamingPolicy;
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
+import com.google.gerrit.testutil.InMemoryRepositoryManager.Repo;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
 
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
-import org.elasticsearch.action.admin.cluster.node.info.NodesInfoRequest;
-import org.elasticsearch.common.settings.Settings;
-import org.elasticsearch.node.Node;
-import org.elasticsearch.node.NodeBuilder;
 import org.junit.After;
 import org.junit.AfterClass;
 import org.junit.BeforeClass;
+import org.junit.Test;
 
-import java.io.File;
-import java.nio.file.Path;
-import java.util.Iterator;
-import java.util.Map;
 import java.util.concurrent.ExecutionException;
 
 public class ElasticQueryChangesTest extends AbstractQueryChangesTest {
-  private static final Gson gson = new GsonBuilder()
-      .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
-      .create();
-  private static Node node;
-  private static String port;
-  private static File elasticDir;
-
-  static class NodeInfo {
-    String httpAddress;
-  }
-
-  static class Info {
-    Map<String, NodeInfo> nodes;
-  }
+  private static final String INDEX_NAME =
+      String.format("%s%04d", CHANGES_PREFIX,
+          ChangeSchemaDefinitions.INSTANCE.getLatest().getVersion());
+  private static ElasticNodeInfo nodeInfo;
 
   @BeforeClass
   public static void startIndexService()
       throws InterruptedException, ExecutionException {
-    if (node != null) {
+    if (nodeInfo != null) {
       // do not start Elasticsearch twice
       return;
     }
-    elasticDir = Files.createTempDir();
-    Path elasticDirPath = elasticDir.toPath();
-    Settings settings = Settings.settingsBuilder()
-        .put("cluster.name", "gerrit")
-        .put("node.name", "Gerrit Elasticsearch Test Node")
-        .put("node.local", true)
-        .put("discovery.zen.ping.multicast.enabled", false)
-        .put("index.store.fs.memory.enabled", true)
-        .put("index.gateway.type", "none")
-        .put("index.max_result_window", Integer.MAX_VALUE)
-        .put("gateway.type", "default")
-        .put("http.port", 0)
-        .put("discovery.zen.ping.unicast.hosts", "[\"localhost\"]")
-        .put("path.home", elasticDirPath.toAbsolutePath())
-        .put("path.data", elasticDirPath.resolve("data").toAbsolutePath())
-        .put("path.work", elasticDirPath.resolve("work").toAbsolutePath())
-        .put("path.logs", elasticDirPath.resolve("logs").toAbsolutePath())
-        .put("transport.tcp.connect_timeout", "60s")
-        .build();
-
-    // Start the node
-    node = NodeBuilder.nodeBuilder()
-        .settings(settings)
-        .node();
-
-    // Wait for it to be ready
-    node.client()
-        .admin()
-        .cluster()
-        .prepareHealth()
-        .setWaitForYellowStatus()
-        .execute()
-        .actionGet();
+    nodeInfo = ElasticTestUtils.startElasticsearchNode();
 
     createIndexes();
-
-    assertThat(node.isClosed()).isFalse();
-    port = getHttpPort();
   }
 
   @After
   public void cleanupIndex() {
-    node.client().admin().indices().prepareDelete("gerrit").execute();
-    createIndexes();
+    if (nodeInfo != null) {
+      ElasticTestUtils.deleteIndexes(nodeInfo.node, INDEX_NAME);
+      createIndexes();
+    }
   }
 
   @AfterClass
   public static void stopElasticsearchServer() {
-    if (node != null) {
-      node.close();
-      node = null;
-    }
-    if (elasticDir != null && elasticDir.delete()) {
-      elasticDir = null;
+    if (nodeInfo != null) {
+      nodeInfo.node.close();
+      nodeInfo.elasticDir.delete();
+      nodeInfo = null;
     }
   }
 
@@ -131,12 +75,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    elasticsearchConfig.setEnum("index", null, "type", IndexType.ELASTICSEARCH);
-    elasticsearchConfig.setString("index", null, "protocol", "http");
-    elasticsearchConfig.setString("index", null, "hostname", "localhost");
-    elasticsearchConfig.setString("index", null, "port", port);
-    elasticsearchConfig.setString("index", null, "name", "gerrit");
-    elasticsearchConfig.setBoolean("index", "elasticsearch", "test", true);
+    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port);
     return Guice.createInjector(
         new InMemoryModule(elasticsearchConfig, notesMigration));
   }
@@ -148,32 +87,25 @@
         new ChangeMapping(ChangeSchemaDefinitions.INSTANCE.getLatest());
     openChangesMapping.closedChanges = null;
     closedChangesMapping.openChanges = null;
-    node.client()
+    nodeInfo.node
+        .client()
         .admin()
         .indices()
-        .prepareCreate("gerrit")
-        .addMapping(OPEN_CHANGES, gson.toJson(openChangesMapping))
-        .addMapping(CLOSED_CHANGES, gson.toJson(closedChangesMapping))
+        .prepareCreate(INDEX_NAME)
+        .addMapping(OPEN_CHANGES,
+            ElasticTestUtils.gson.toJson(openChangesMapping))
+        .addMapping(CLOSED_CHANGES,
+            ElasticTestUtils.gson.toJson(closedChangesMapping))
         .execute()
         .actionGet();
   }
 
-  private static String getHttpPort()
-      throws InterruptedException, ExecutionException {
-    String nodes = node.client().admin().cluster()
-        .nodesInfo(new NodesInfoRequest("*")).get().toString();
-    Gson gson = new GsonBuilder()
-        .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
-        .create();
-    Info info = gson.fromJson(nodes, Info.class);
-
-    checkState(info.nodes != null && info.nodes.size() == 1);
-    Iterator<NodeInfo> values = info.nodes.values().iterator();
-    String httpAddress = values.next().httpAddress;
-
-    checkState(
-        !Strings.isNullOrEmpty(httpAddress) && httpAddress.indexOf(':') > 0);
-    return httpAddress.substring(httpAddress.indexOf(':') + 1,
-        httpAddress.length());
+  @Test
+  public void byOwnerInvalidQuery() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    insert(repo, newChange(repo), userId);
+    String nameEmail = user.asIdentifiedUser().getNameEmail();
+    assertQuery("owner: \"" + nameEmail + "\"\\");
   }
+
 }
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticTestUtils.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticTestUtils.java
new file mode 100644
index 0000000..7c26edc
--- /dev/null
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticTestUtils.java
@@ -0,0 +1,144 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.base.Strings;
+import com.google.common.io.Files;
+import com.google.gerrit.server.index.IndexModule.IndexType;
+import com.google.gson.FieldNamingPolicy;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+
+import org.eclipse.jgit.lib.Config;
+import org.elasticsearch.action.admin.cluster.node.info.NodesInfoRequest;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.node.Node;
+import org.elasticsearch.node.NodeBuilder;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+
+final class ElasticTestUtils {
+  static final Gson gson = new GsonBuilder()
+      .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+      .create();
+
+  static class ElasticNodeInfo {
+    final Node node;
+    final String port;
+    final File elasticDir;
+
+    private ElasticNodeInfo(Node node, File rootDir, String port) {
+      this.node = node;
+      this.port = port;
+      this.elasticDir = rootDir;
+    }
+  }
+
+  static void configure(Config config, String port) {
+    config.setEnum("index", null, "type", IndexType.ELASTICSEARCH);
+    config.setString("index", null, "protocol", "http");
+    config.setString("index", null, "hostname", "localhost");
+    config.setString("index", null, "port", port);
+    config.setBoolean("index", "elasticsearch", "test", true);
+  }
+
+  static ElasticNodeInfo startElasticsearchNode()
+      throws InterruptedException, ExecutionException {
+    File elasticDir = Files.createTempDir();
+    Path elasticDirPath = elasticDir.toPath();
+    Settings settings = Settings.settingsBuilder()
+        .put("cluster.name", "gerrit")
+        .put("node.name", "Gerrit Elasticsearch Test Node")
+        .put("node.local", true)
+        .put("discovery.zen.ping.multicast.enabled", false)
+        .put("index.store.fs.memory.enabled", true)
+        .put("index.gateway.type", "none")
+        .put("index.max_result_window", Integer.MAX_VALUE)
+        .put("gateway.type", "default")
+        .put("http.port", 0)
+        .put("discovery.zen.ping.unicast.hosts", "[\"localhost\"]")
+        .put("path.home", elasticDirPath.toAbsolutePath())
+        .put("path.data", elasticDirPath.resolve("data").toAbsolutePath())
+        .put("path.work", elasticDirPath.resolve("work").toAbsolutePath())
+        .put("path.logs", elasticDirPath.resolve("logs").toAbsolutePath())
+        .put("transport.tcp.connect_timeout", "60s")
+        .build();
+
+    // Start the node
+    Node node = NodeBuilder.nodeBuilder()
+        .settings(settings)
+        .node();
+
+    // Wait for it to be ready
+    node.client()
+        .admin()
+        .cluster()
+        .prepareHealth()
+        .setWaitForYellowStatus()
+        .execute()
+        .actionGet();
+
+    assertThat(node.isClosed()).isFalse();
+    return new ElasticNodeInfo(node, elasticDir, getHttpPort(node));
+  }
+
+  static void deleteIndexes(Node node, String index) {
+    node.client().admin().indices().prepareDelete(index).execute();
+  }
+
+  static class NodeInfo {
+    String httpAddress;
+  }
+
+  static class Info {
+    Map<String, NodeInfo> nodes;
+  }
+
+  private static String getHttpPort(Node node)
+      throws InterruptedException, ExecutionException {
+    String nodes = node.client().admin().cluster()
+        .nodesInfo(new NodesInfoRequest("*")).get().toString();
+    Gson gson = new GsonBuilder()
+        .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+        .create();
+    Info info = gson.fromJson(nodes, Info.class);
+    if (info.nodes == null || info.nodes.size() != 1) {
+      throw new RuntimeException(
+          "Cannot extract local Elasticsearch http port");
+    }
+    Iterator<NodeInfo> values = info.nodes.values().iterator();
+    String httpAddress = values.next().httpAddress;
+    if (Strings.isNullOrEmpty(httpAddress)) {
+      throw new RuntimeException(
+          "Cannot extract local Elasticsearch http port");
+    }
+    if (httpAddress.indexOf(':') < 0) {
+      throw new RuntimeException(
+          "Seems that port is not included in Elasticsearch http_address");
+    }
+    return httpAddress.substring(httpAddress.indexOf(':') + 1,
+        httpAddress.length());
+  }
+
+  private ElasticTestUtils() {
+    // hide default constructor
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
index 9765bbf..63ac914 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
@@ -77,6 +77,8 @@
   List<AgreementInfo> listAgreements() throws RestApiException;
   void signAgreement(String agreementName) throws RestApiException;
 
+  void index() throws RestApiException;
+
   /**
    * A default implementation which allows source compatibility
    * when adding new methods to the interface.
@@ -224,5 +226,10 @@
     public void signAgreement(String agreementName) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void index() throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ActionVisitor.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ActionVisitor.java
new file mode 100644
index 0000000..6aa7f0c
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ActionVisitor.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+
+/**
+ * Extension point called during population of {@link ActionInfo} maps.
+ * <p>
+ * Each visitor may mutate the input {@link ActionInfo}, or filter it out of the
+ * map entirely. When multiple extensions are registered, the order in which
+ * they are executed is undefined.
+ */
+@ExtensionPoint
+public interface ActionVisitor {
+  /**
+   * Visit a change-level action.
+   * <p>
+   * Callers may mutate the input {@link ActionInfo}, or return false to omit
+   * the action from the map entirely. Inputs other than the {@link ActionInfo}
+   * should be considered immutable.
+   *
+   * @param name name of the action, as a key into the {@link ActionInfo} map
+   *     returned by the REST API.
+   * @param actionInfo action being visited; caller may mutate.
+   * @param changeInfo information about the change to which this action
+   *     belongs; caller should treat as immutable.
+   * @return true if the action should remain in the map, or false to omit it.
+   */
+  boolean visit(String name, ActionInfo actionInfo, ChangeInfo changeInfo);
+
+  /**
+   * Visit a revision-level action.
+   * <p>
+   * Callers may mutate the input {@link ActionInfo}, or return false to omit
+   * the action from the map entirely. Inputs other than the {@link ActionInfo}
+   * should be considered immutable.
+   *
+   * @param name name of the action, as a key into the {@link ActionInfo} map
+   *     returned by the REST API.
+   * @param actionInfo action being visited; caller may mutate.
+   * @param changeInfo information about the change to which this action
+   *     belongs; caller should treat as immutable.
+   * @param revisionInfo information about the revision to which this action
+   *     belongs; caller should treat as immutable.
+   * @return true if the action should remain in the map, or false to omit it.
+   */
+  boolean visit(String name, ActionInfo actionInfo, ChangeInfo changeInfo,
+      RevisionInfo revisionInfo);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 629ad97..a545cad 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -163,6 +163,8 @@
 
   /**
    * Delete the assignee of a change.
+   *
+   * @return the assignee that was deleted, or null if there was no assignee.
    */
   AccountInfo deleteAssignee() throws RestApiException;
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index 8ada55a..0155764 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -33,6 +33,9 @@
 public interface RevisionApi {
   void delete() throws RestApiException;
 
+  String description() throws RestApiException;
+  void description(String description) throws RestApiException;
+
   void review(ReviewInput in) throws RestApiException;
 
   void submit() throws RestApiException;
@@ -68,6 +71,8 @@
   CommentApi comment(String id) throws RestApiException;
   RobotCommentApi robotComment(String id) throws RestApiException;
 
+  String etag() throws RestApiException;
+
   /**
    * Returns patch of revision.
    */
@@ -283,5 +288,20 @@
     public MergeListRequest getMergeList() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void description(String description) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public String description() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public String etag() throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchInfo.java
index 77513a2..8ef1b8e 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchInfo.java
@@ -21,7 +21,6 @@
 import java.util.Map;
 
 public class BranchInfo extends RefInfo {
-  public Boolean canDelete;
   public Map<String, ActionInfo> actions;
   public List<WebLinkInfo> webLinks;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/RefInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/RefInfo.java
index 1844a76..c573600 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/RefInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/RefInfo.java
@@ -17,4 +17,5 @@
 public class RefInfo {
   public String ref;
   public String revision;
+  public Boolean canDelete;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 8d82e3a..3813644 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -141,6 +141,7 @@
   public Boolean muteCommonPathPrefixes;
   public Boolean signedOffBy;
   public List<MenuItem> my;
+  public List<String> changeTable;
   public Map<String, String> urlAliases;
   public EmailStrategy emailStrategy;
   public DefaultBase defaultBaseForMerges;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ApprovalInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ApprovalInfo.java
index d59e813..9125bfd 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ApprovalInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ApprovalInfo.java
@@ -21,6 +21,7 @@
   public Integer value;
   public Timestamp date;
   public Boolean postSubmit;
+  public VotingRangeInfo permittedVotingRange;
 
   public ApprovalInfo(Integer id) {
     super(id);
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/VotingRangeInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/VotingRangeInfo.java
new file mode 100644
index 0000000..5c35a49
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/VotingRangeInfo.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+public class VotingRangeInfo {
+  public int min;
+  public int max;
+
+  public VotingRangeInfo(int min, int max) {
+    this.min = min;
+    this.max = max;
+  }
+}
diff --git a/gerrit-gwtdebug/BUILD b/gerrit-gwtdebug/BUILD
new file mode 100644
index 0000000..a1a04b0
--- /dev/null
+++ b/gerrit-gwtdebug/BUILD
@@ -0,0 +1,17 @@
+java_library(
+  name = 'gwtdebug',
+  srcs = glob(['src/main/java/**/*.java']),
+  deps = [
+    '//gerrit-pgm:daemon',
+    '//gerrit-pgm:pgm',
+    '//gerrit-pgm:util',
+    '//gerrit-util-cli:cli',
+    '//lib/gwt:dev',
+    '//lib/jetty:server',
+    '//lib/jetty:servlet',
+    '//lib/jetty:servlets',
+    '//lib/log:api',
+    '//lib/log:log4j',
+  ],
+  visibility = ['//visibility:public'],
+)
diff --git a/gerrit-gwtexpui/BUILD b/gerrit-gwtexpui/BUILD
index d74fc8b..610a10b 100644
--- a/gerrit-gwtexpui/BUILD
+++ b/gerrit-gwtexpui/BUILD
@@ -16,7 +16,7 @@
   deps = [
     ':SafeHtml',
     ':UserAgent',
-    '//lib/gwt:user',
+    '//lib/gwt:user-neverlink',
   ],
   visibility = ['//visibility:public'],
   data = [
diff --git a/gerrit-gwtui-common/BUILD b/gerrit-gwtui-common/BUILD
index c6ad882..ac31856 100644
--- a/gerrit-gwtui-common/BUILD
+++ b/gerrit-gwtui-common/BUILD
@@ -10,7 +10,7 @@
   '//gerrit-gwtexpui:SafeHtml',
   '//gerrit-gwtexpui:UserAgent',
 ]
-DEPS = ['//lib/gwt:user']
+DEPS = ['//lib/gwt:user-neverlink']
 SRC = 'src/main/java/com/google/gerrit/'
 
 gwt_module(
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
index ff060b8..054bfdd 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
@@ -305,10 +305,20 @@
     public final native boolean hasValue() /*-{ return this.hasOwnProperty('value'); }-*/;
     public final native short value() /*-{ return this.value || 0; }-*/;
 
+    public final native VotingRangeInfo permittedVotingRange() /*-{ return this.permitted_voting_range; }-*/;
+
     protected ApprovalInfo() {
     }
   }
 
+  public static class VotingRangeInfo extends AccountInfo {
+    public final native short min() /*-{ return this.min || 0; }-*/;
+    public final native short max() /*-{ return this.max || 0; }-*/;
+
+    protected VotingRangeInfo() {
+    }
+  }
+
   public static class EditInfo extends JavaScriptObject {
     public final native String name() /*-{ return this.name; }-*/;
     public final native String setName(String n) /*-{ this.name = n; }-*/;
diff --git a/gerrit-gwtui/BUILD b/gerrit-gwtui/BUILD
index 7e692e8..9e04609 100644
--- a/gerrit-gwtui/BUILD
+++ b/gerrit-gwtui/BUILD
@@ -1,6 +1,7 @@
 load('//tools/bzl:gwt.bzl', 'gwt_genrule', 'gen_ui_module',
      'gwt_user_agent_permutations')
 load('//tools/bzl:license.bzl', 'license_test')
+load('//tools/bzl:junit.bzl', 'junit_tests')
 
 gwt_genrule()
 gwt_genrule('_r')
@@ -14,3 +15,17 @@
   name = "ui_module_license_test",
   target = ":ui_module",
 )
+
+junit_tests(
+  name = 'ui_tests',
+  srcs = glob(['src/test/java/**/*.java']),
+  deps = [
+    ':ui_module',
+    '//gerrit-common:client',
+    '//gerrit-extension-api:client',
+    '//lib:junit',
+    '//lib/gwt:dev',
+    '//lib/gwt:user',
+  ],
+  visibility = ['//visibility:public'],
+)
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/MessageOfTheDayBar.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/MessageOfTheDayBar.java
index 054cdb3..de1bd93 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/MessageOfTheDayBar.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/MessageOfTheDayBar.java
@@ -46,10 +46,10 @@
     initWidget(uiBinder.createAndBindUi(this));
 
     SafeHtmlBuilder b = new SafeHtmlBuilder();
-    if (motd.size() == 1) {
-      b.append(SafeHtml.asis(motd.get(0).html));
+    if (this.motd.size() == 1) {
+      b.append(SafeHtml.asis(this.motd.get(0).html));
     } else {
-      for (HostPageData.Message m : motd) {
+      for (HostPageData.Message m : this.motd) {
         b.openDiv();
         b.append(SafeHtml.asis(m.html));
         b.openElement("hr");
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/access/ProjectAccessInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/access/ProjectAccessInfo.java
index 7cfb1fc..ae93a83 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/access/ProjectAccessInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/access/ProjectAccessInfo.java
@@ -19,6 +19,7 @@
 public class ProjectAccessInfo extends JavaScriptObject {
   public final native boolean canAddRefs() /*-{ return this.can_add ? true : false; }-*/;
   public final native boolean isOwner() /*-{ return this.is_owner ? true : false; }-*/;
+  public final native boolean configVisible() /*-{ return this.config_visible ? true : false; }-*/;
 
   protected ProjectAccessInfo() {
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
index e1cfa90..24c2da7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
@@ -72,6 +72,7 @@
 
 public class ProjectInfoScreen extends ProjectScreen {
   private boolean isOwner;
+  private boolean configVisible;
 
   private LabeledWidgetsGrid grid;
   private Panel pluginOptionsPanel;
@@ -154,6 +155,7 @@
           @Override
           public void onSuccess(ProjectAccessInfo result) {
             isOwner = result.isOwner();
+            configVisible = result.configVisible();
             enableForm();
             saveProject.setVisible(isOwner);
           }
@@ -625,7 +627,7 @@
       actionsPanel.add(createChangeAction());
     }
 
-    if (isOwner) {
+    if (isOwner && configVisible) {
       actionsPanel.add(createEditConfigAction());
     }
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java
index 36107ee..779c32b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java
@@ -36,8 +36,20 @@
 
 class Actions extends Composite {
   private static final String[] CORE = {
-    "abandon", "cherrypick", "followup", "hashtags", "publish",
-    "rebase", "restore", "revert", "submit", "topic", "/",};
+    "abandon",
+    "assignee",
+    "cherrypick",
+    "description",
+    "followup",
+    "hashtags",
+    "publish",
+    "rebase",
+    "restore",
+    "revert",
+    "submit",
+    "topic",
+    "/",
+  };
 
   interface Binder extends UiBinder<FlowPanel, Actions> {}
   private static final Binder uiBinder = GWT.create(Binder.class);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.java
index f1489bb..7d6b1c3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.java
@@ -113,6 +113,8 @@
     if (currentAssignee != null) {
       suggestBox.setText(FormatUtil.nameEmail(currentAssignee));
       suggestBox.selectAll();
+    } else {
+      suggestBox.setText("");
     }
   }
 
@@ -137,7 +139,7 @@
   }
 
   private void editAssignee(final String assignee) {
-    if (assignee.isEmpty()) {
+    if (assignee.trim().isEmpty()) {
       ChangeApi.deleteAssignee(changeId.get(),
           new GerritCallback<AccountInfo>() {
             @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
index fa3855e..794425e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
@@ -462,7 +462,8 @@
   }
 
   private void gotoSibling(int offset) {
-    if (offset > 0 && changeInfo.currentRevision().equals(revision)) {
+    if (offset > 0 && changeInfo.currentRevision() != null
+        && changeInfo.currentRevision().equals(revision)) {
       return;
     }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CommitBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CommitBox.java
index 9ec1356..8f00a45 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CommitBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CommitBox.java
@@ -56,7 +56,6 @@
     String collapsed();
     String expanded();
     String clippy();
-    String parentWebLink();
   }
 
   @UiField Style style;
@@ -67,7 +66,6 @@
   @UiField FlowPanel webLinkPanel;
   @UiField TableRowElement firstParent;
   @UiField FlowPanel parentCommits;
-  @UiField FlowPanel parentWebLinks;
   @UiField InlineHyperlink authorNameEmail;
   @UiField Element authorDate;
   @UiField InlineHyperlink committerNameEmail;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CommitBox.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CommitBox.ui.xml
index 5f476be..19f115d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CommitBox.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CommitBox.ui.xml
@@ -88,13 +88,6 @@
       margin-left:2px;
     }
 
-    .parentWebLink a:first-child {
-      margin-left:16px;
-    }
-    .parentWebLink>a {
-      margin-left:2px;
-    }
-
     .commit {
       margin-right: 3px;
       float: left;
@@ -185,9 +178,6 @@
         <td>
           <g:FlowPanel ui:field='parentCommits'/>
         </td>
-        <td>
-          <g:FlowPanel ui:field='parentWebLinks' styleName='{style.parentWebLink}'/>
-        </td>
       </tr>
       <tr>
         <th><ui:msg>Change-Id</ui:msg></th>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChanges.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChanges.java
index 2d868da..0d0dba7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChanges.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChanges.java
@@ -215,7 +215,8 @@
         EnumSet.of(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT),
         new TabChangeListCallback(Tab.CHERRY_PICKS, info.project(), revision));
 
-    if (info.currentRevision().equals(revision)) {
+    if (info.currentRevision() != null
+        && info.currentRevision().equals(revision)) {
       ChangeApi.change(info.legacyId().get()).view("submitted_together")
           .get(new TabChangeListCallback(Tab.SUBMITTED_TOGETHER,
               info.project(), revision));
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java
index e0c252c..6b4cf36 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java
@@ -255,6 +255,7 @@
     Map<Integer, VotableInfo> d = new HashMap<>();
     for (String name : change.labels()) {
       LabelInfo label = change.label(name);
+      short labelMaxValue = LabelInfo.parseValue(label.maxValue());
       if (label.all() != null) {
         for (ApprovalInfo ai : Natives.asList(label.all())) {
           int id = ai._accountId();
@@ -263,7 +264,10 @@
             ad = new VotableInfo();
             d.put(id, ad);
           }
-          if (ai.hasValue()) {
+          if (ai.permittedVotingRange() != null
+              && ai.permittedVotingRange().max() == labelMaxValue) {
+            ad.votable(name + " (" + label.maxValue() + ") ");
+          } else if (ai.hasValue()) {
             ad.votable(name);
           }
         }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/AccountDashboardScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/AccountDashboardScreen.java
index 62c14cb..fb66570 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/AccountDashboardScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/AccountDashboardScreen.java
@@ -95,11 +95,13 @@
   }
 
   private static String queryIncoming(String who) {
-    return "is:open reviewer:" + who + " -owner:" + who + " -star:ignore";
+    return "is:open ((reviewer:" + who + " -owner:" + who
+        + " -star:ignore) OR assignee:" + who + ")";
   }
 
   private static String queryClosed(String who) {
-    return "is:closed (owner:" + who + " OR reviewer:" + who + ")";
+    return "is:closed (owner:" + who + " OR reviewer:" + who + " OR assignee:"
+        + who + ")";
   }
 
   @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
index a008149..84c2403 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
@@ -122,7 +122,7 @@
       AsyncCallback<AccountInfo> cb) {
     AssigneeInput input = AssigneeInput.create();
     input.assignee(user);
-    change(id).view("assignee").put(user, cb);
+    change(id).view("assignee").put(input, cb);
   }
 
   public static RestApi comments(int id) {
diff --git a/gerrit-httpd/BUILD b/gerrit-httpd/BUILD
index 1341ad1..7b20e70 100644
--- a/gerrit-httpd/BUILD
+++ b/gerrit-httpd/BUILD
@@ -1,3 +1,7 @@
+package(
+  default_visibility=["//visibility:public"]
+)
+
 load('//tools/bzl:junit.bzl', 'junit_tests')
 
 SRCS = glob(
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
index ac7c7e7..db8808d 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
@@ -42,6 +42,7 @@
 import com.google.gerrit.server.config.GitwebCgiConfig;
 import com.google.gerrit.server.config.GitwebConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.LocalDiskRepositoryManager;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectControl;
@@ -49,6 +50,7 @@
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
 
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -100,7 +102,7 @@
   private final EnvList _env;
 
   @Inject
-  GitwebServlet(LocalDiskRepositoryManager repoManager,
+  GitwebServlet(GitRepositoryManager repoManager,
       ProjectControl.Factory projectControl,
       Provider<AnonymousUser> anonymousUserProvider,
       Provider<CurrentUser> userProvider,
@@ -110,7 +112,11 @@
       GitwebConfig gitwebConfig,
       GitwebCgiConfig gitwebCgiConfig)
       throws IOException {
-    this.repoManager = repoManager;
+    if (!(repoManager instanceof LocalDiskRepositoryManager)) {
+      throw new ProvisionException(
+          "Gitweb can only be used with LocalDiskRepositoryManager");
+    }
+    this.repoManager = (LocalDiskRepositoryManager)repoManager;
     this.projectControl = projectControl;
     this.anonymousUserProvider = anonymousUserProvider;
     this.userProvider = userProvider;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/WrappedFilterConfig.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/WrappedFilterConfig.java
index 04e49c9..60ceeb9 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/WrappedFilterConfig.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/WrappedFilterConfig.java
@@ -39,10 +39,9 @@
     return null;
   }
 
-  @SuppressWarnings({"rawtypes", "unchecked"})
   @Override
-  public Enumeration getInitParameterNames() {
-    return Collections.enumeration(Collections.emptyList());
+  public Enumeration<String> getInitParameterNames() {
+    return Collections.emptyEnumeration();
   }
 
   @Override
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BazelBuild.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BazelBuild.java
new file mode 100644
index 0000000..594d209
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BazelBuild.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.raw;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Properties;
+
+public class BazelBuild extends BuildSystem {
+  public BazelBuild(Path sourceRoot) {
+    super(sourceRoot);
+  }
+
+  @Override
+  protected ProcessBuilder newBuildProcess(Label label) throws IOException {
+    Properties properties = loadBuildProperties(
+        sourceRoot.resolve(".primary_build_tool"));
+    String buck = firstNonNull(properties.getProperty("bazel"), "bazel");
+    ProcessBuilder proc = new ProcessBuilder(buck, "build", label.fullName());
+    if (properties.containsKey("PATH")) {
+      proc.environment().put("PATH", properties.getProperty("PATH"));
+    }
+    return proc;
+  }
+
+  @Override
+  public String buildCommand(Label l) {
+    return "bazel build " + l.toString();
+  }
+
+  @Override
+  public Path targetPath(Label l) {
+    return sourceRoot.resolve("bazel-bin").resolve(l.pkg).resolve(l.name);
+  }
+
+  @Override
+  public Label gwtZipLabel(String agent) {
+    return new Label("gerrit-gwtui", "ui_" + agent + ".zip");
+  }
+
+  @Override
+  public Label polygerritComponents() {
+    return new Label("polygerrit-ui",
+        "polygerrit_components.bower_components.zip");
+  }
+
+  @Override
+  public Label fontZipLabel() {
+    return new Label("polygerrit-ui", "fonts.zip");
+  }
+
+  @Override
+  public String name() {
+    return "bazel";
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BowerComponentsDevServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BowerComponentsDevServlet.java
new file mode 100644
index 0000000..027a04e
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BowerComponentsDevServlet.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.raw;
+
+import com.google.common.cache.Cache;
+import com.google.gerrit.httpd.raw.BuildSystem.Label;
+import com.google.gerrit.launcher.GerritLauncher;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Objects;
+
+/* Bower component servlet only used in development mode */
+class BowerComponentsDevServlet extends ResourceServlet {
+  private static final long serialVersionUID = 1L;
+
+  private final Path bowerComponents;
+  private final Path zip;
+
+  BowerComponentsDevServlet(Cache<Path, Resource> cache,
+      BuildSystem builder) throws IOException {
+    super(cache, true);
+
+    Objects.requireNonNull(builder);
+    Label label = builder.polygerritComponents();
+    try {
+      builder.build(label);
+    } catch (BuildSystem.BuildFailureException e) {
+      throw new IOException(e);
+    }
+
+    zip = builder.targetPath(label);
+    bowerComponents = GerritLauncher
+          .newZipFileSystem(zip)
+          .getPath("/");
+  }
+
+  @Override
+  protected Path getResourcePath(String pathInfo) throws IOException {
+    return bowerComponents.resolve(pathInfo);
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BowerComponentsServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BowerComponentsServlet.java
deleted file mode 100644
index 3e0f833..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BowerComponentsServlet.java
+++ /dev/null
@@ -1,61 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.raw;
-
-import com.google.common.cache.Cache;
-import com.google.gerrit.launcher.GerritLauncher;
-
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-
-class BowerComponentsServlet extends ResourceServlet {
-  private static final long serialVersionUID = 1L;
-
-  private final Path zip;
-  private final Path bowerComponents;
-
-  BowerComponentsServlet(Cache<Path, Resource> cache, Path buckOut)
-      throws IOException {
-    super(cache, true);
-    zip = getZipPath(buckOut);
-    if (zip == null || !Files.exists(zip)) {
-      bowerComponents = null;
-    } else {
-      bowerComponents = GerritLauncher
-          .newZipFileSystem(zip)
-          .getPath("/");
-    }
-  }
-
-  @Override
-  protected Path getResourcePath(String pathInfo) throws IOException {
-    if (bowerComponents == null) {
-      throw new IOException("No polymer components found: " + zip
-          + ". Run `buck build //polygerrit-ui:polygerrit_components`?");
-    }
-    return bowerComponents.resolve(pathInfo);
-  }
-
-  private static Path getZipPath(Path buckOut) {
-    if (buckOut == null) {
-      return null;
-    }
-    return buckOut.resolve("gen")
-        .resolve("polygerrit-ui")
-        .resolve("polygerrit_components")
-        .resolve("polygerrit_components.bower_components.zip");
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BuckUtils.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BuckUtils.java
index 0b4a02e..7d85877 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BuckUtils.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BuckUtils.java
@@ -15,104 +15,65 @@
 package com.google.gerrit.httpd.raw;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.escape.Escaper;
-import com.google.common.html.HtmlEscapers;
-import com.google.common.io.ByteStreams;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gwtexpui.server.CacheHeaders;
-
-import org.eclipse.jgit.util.RawParseUtils;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
-import java.io.InputStream;
-import java.io.InterruptedIOException;
-import java.io.PrintWriter;
-import java.nio.file.Files;
-import java.nio.file.NoSuchFileException;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.util.Properties;
 
-import javax.servlet.http.HttpServletResponse;
+class BuckUtils extends BuildSystem {
+  BuckUtils(Path sourceRoot) {
+    super(sourceRoot);
+  }
 
-class BuckUtils {
-  private static final Logger log =
-      LoggerFactory.getLogger(BuckUtils.class);
-
-  static void build(Path root, Path gen, String target)
-      throws IOException, BuildFailureException {
-    log.info("buck build " + target);
-    Properties properties = loadBuckProperties(gen);
+  @Override
+  protected ProcessBuilder newBuildProcess(Label label) throws IOException {
+    Properties properties = loadBuildProperties(
+        sourceRoot.resolve("buck-out/gen/tools/buck/buck.properties"));
     String buck = firstNonNull(properties.getProperty("buck"), "buck");
-    ProcessBuilder proc = new ProcessBuilder(buck, "build", target)
-        .directory(root.toFile())
-        .redirectErrorStream(true);
+    ProcessBuilder proc = new ProcessBuilder(buck, "build", label.fullName());
     if (properties.containsKey("PATH")) {
       proc.environment().put("PATH", properties.getProperty("PATH"));
     }
-    long start = TimeUtil.nowMs();
-    Process rebuild = proc.start();
-    byte[] out;
-    try (InputStream in = rebuild.getInputStream()) {
-      out = ByteStreams.toByteArray(in);
-    } finally {
-      rebuild.getOutputStream().close();
-    }
-
-    int status;
-    try {
-      status = rebuild.waitFor();
-    } catch (InterruptedException e) {
-      throw new InterruptedIOException("interrupted waiting for " + buck);
-    }
-    if (status != 0) {
-      throw new BuildFailureException(out);
-    }
-
-    long time = TimeUtil.nowMs() - start;
-    log.info(String.format("UPDATED    %s in %.3fs", target, time / 1000.0));
+    return proc;
   }
 
-  private static Properties loadBuckProperties(Path gen) throws IOException {
-    Properties properties = new Properties();
-    Path p = gen.resolve(Paths.get("tools/buck/buck.properties"));
-    try (InputStream in = Files.newInputStream(p)) {
-      properties.load(in);
-    } catch (NoSuchFileException e) {
-      // Ignore; will be run from PATH, with a descriptive error if it fails.
-    }
-    return properties;
+  @Override
+  public Path targetPath(Label label) {
+    return sourceRoot.resolve("buck-out")
+        .resolve("gen").resolve(label.artifact);
   }
 
-  static void displayFailure(String rule, byte[] why, HttpServletResponse res)
-      throws IOException {
-    res.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
-    res.setContentType("text/html");
-    res.setCharacterEncoding(UTF_8.name());
-    CacheHeaders.setNotCacheable(res);
-
-    Escaper html = HtmlEscapers.htmlEscaper();
-    try (PrintWriter w = res.getWriter()) {
-      w.write("<html><title>BUILD FAILED</title><body>");
-      w.format("<h1>%s FAILED</h1>", html.escape(rule));
-      w.write("<pre>");
-      w.write(html.escape(RawParseUtils.decode(why)));
-      w.write("</pre>");
-      w.write("</body></html>");
-    }
+  @Override
+  public String buildCommand(Label l) {
+    return "buck build " + l.toString();
   }
 
-  static class BuildFailureException extends Exception {
-    private static final long serialVersionUID = 1L;
+  @Override
+  public Label gwtZipLabel(String agent) {
+    // TODO(davido): instead of assuming specific Buck's internal
+    // target directory for gwt_binary() artifacts, ask Buck for
+    // the location of user agent permutation GWT zip, e. g.:
+    // $ buck targets --show_output //gerrit-gwtui:ui_safari \
+    //    | awk '{print $2}'
+    String t = "ui_" + agent;
+    return new BuildSystem.Label("gerrit-gwtui", t,
+        String.format("gerrit-gwtui/__gwt_binary_%s__/%s.zip", t, t));
+  }
 
-    final byte[] why;
+  @Override
+  public Label polygerritComponents() {
+    return new Label("polygerrit-ui", "polygerrit_components",
+        "polygerrit-ui/polygerrit_components/" +
+        "polygerrit_components.bower_components.zip");
+  }
 
-    BuildFailureException(byte[] why) {
-      this.why = why;
-    }
+  @Override
+  public Label fontZipLabel() {
+    return new Label("polygerrit-ui", "fonts", "polygerrit-ui/fonts/fonts.zip");
+  }
+
+  @Override
+  public String name() {
+    return "buck";
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BuildSystem.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BuildSystem.java
new file mode 100644
index 0000000..76d3110
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BuildSystem.java
@@ -0,0 +1,176 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.raw;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.escape.Escaper;
+import com.google.common.html.HtmlEscapers;
+import com.google.common.io.ByteStreams;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gwtexpui.server.CacheHeaders;
+
+import org.eclipse.jgit.util.RawParseUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InterruptedIOException;
+import java.io.PrintWriter;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.util.Properties;
+
+import javax.servlet.http.HttpServletResponse;
+
+public abstract class BuildSystem {
+  private static final Logger log =
+      LoggerFactory.getLogger(BuildSystem.class);
+
+  protected final Path sourceRoot;
+
+  public BuildSystem(Path sourceRoot) {
+    this.sourceRoot = sourceRoot;
+  }
+
+  protected abstract ProcessBuilder newBuildProcess(Label l) throws IOException;
+
+  protected static Properties loadBuildProperties(Path propPath)
+      throws IOException {
+    Properties properties = new Properties();
+    try (InputStream in = Files.newInputStream(propPath)) {
+      properties.load(in);
+    } catch (NoSuchFileException e) {
+      // Ignore; will be run from PATH, with a descriptive error if it fails.
+    }
+    return properties;
+  }
+
+  // builds the given label.
+  public void build(Label label)
+      throws IOException, BuildFailureException {
+    ProcessBuilder proc = newBuildProcess(label);
+    proc.directory(sourceRoot.toFile())
+        .redirectErrorStream(true);
+    log.info("building [" + name() + "] " + label.fullName());
+    long start = TimeUtil.nowMs();
+    Process rebuild = proc.start();
+    byte[] out;
+    try (InputStream in = rebuild.getInputStream()) {
+      out = ByteStreams.toByteArray(in);
+    } finally {
+      rebuild.getOutputStream().close();
+    }
+
+    int status;
+    try {
+      status = rebuild.waitFor();
+    } catch (InterruptedException e) {
+      throw new InterruptedIOException("interrupted waiting for " + proc.toString());
+    }
+    if (status != 0) {
+      log.warn("build failed: " + new String(out));
+      throw new BuildFailureException(out);
+    }
+
+    long time = TimeUtil.nowMs() - start;
+    log.info(String.format("UPDATED    %s in %.3fs", label.fullName(),
+        time / 1000.0));
+  }
+
+  // Represents a label in either buck or bazel.
+  class Label {
+    protected final String pkg;
+    protected final String name;
+
+    // Regrettably, buck confounds rule names and artifact names,
+    // and so we have to lug this along. Non-null only for Buck; in that case,
+    // holds the path relative to buck-out/gen/
+    protected final String artifact;
+
+    public String fullName() {
+      return  "//" + pkg + ":" + name;
+    }
+
+    @Override
+    public String toString() {
+      String s = fullName();
+      if (!name.equals(artifact)) {
+        s += "(" + artifact + ")";
+      }
+      return s;
+    }
+
+    // Label in Buck style.
+    Label(String pkg, String name, String artifact) {
+      this.name = name;
+      this.pkg = pkg;
+      this.artifact = artifact;
+    }
+
+    // Label in Bazel style.
+    Label(String pkg, String name) {
+      this(pkg, name, name);
+    }
+  }
+
+  class BuildFailureException extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    final byte[] why;
+
+    BuildFailureException(byte[] why) {
+      this.why = why;
+    }
+
+    public void display(String rule, HttpServletResponse res)
+        throws IOException {
+      res.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+      res.setContentType("text/html");
+      res.setCharacterEncoding(UTF_8.name());
+      CacheHeaders.setNotCacheable(res);
+
+      Escaper html = HtmlEscapers.htmlEscaper();
+      try (PrintWriter w = res.getWriter()) {
+        w.write("<html><title>BUILD FAILED</title><body>");
+        w.format("<h1>%s FAILED</h1>", html.escape(rule));
+        w.write("<pre>");
+        w.write(html.escape(RawParseUtils.decode(why)));
+        w.write("</pre>");
+        w.write("</body></html>");
+      }
+    }
+  }
+
+  /** returns the command to build given target */
+  abstract String buildCommand(Label l);
+
+  /** returns the root relative path to the artifact for the given label */
+  abstract Path targetPath(Label l);
+
+  /** Label for the agent specific GWT zip. */
+  abstract Label gwtZipLabel(String agent);
+
+  /** Label for the polygerrit component zip. */
+  abstract Label polygerritComponents();
+
+  /** Label for the fonts zip file. */
+  abstract Label fontZipLabel();
+
+  /** Build system name. */
+  abstract String name();
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/FontsDevServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/FontsDevServlet.java
new file mode 100644
index 0000000..b7b650a
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/FontsDevServlet.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.raw;
+
+import com.google.common.cache.Cache;
+import com.google.gerrit.launcher.GerritLauncher;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Objects;
+
+/* Font servlet only used in development mode */
+class FontsDevServlet extends ResourceServlet {
+  private static final long serialVersionUID = 1L;
+
+  private final Path fonts;
+
+  FontsDevServlet(Cache<Path, Resource> cache, BuildSystem builder)
+      throws IOException {
+    super(cache, true);
+    Objects.requireNonNull(builder);
+
+    BuildSystem.Label zipLabel = builder.fontZipLabel();
+    try {
+      builder.build(zipLabel);
+    } catch (BuildSystem.BuildFailureException e) {
+      throw new IOException(e);
+    }
+
+    Path zip = builder.targetPath(zipLabel);
+    Objects.requireNonNull(zip);
+
+    fonts = GerritLauncher.newZipFileSystem(zip).getPath("/");
+  }
+
+  @Override
+  protected Path getResourcePath(String pathInfo) throws IOException {
+    return fonts.resolve(pathInfo);
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/FontsServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/FontsServlet.java
deleted file mode 100644
index 3a8c8cb..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/FontsServlet.java
+++ /dev/null
@@ -1,61 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.raw;
-
-import com.google.common.cache.Cache;
-import com.google.gerrit.launcher.GerritLauncher;
-
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-
-class FontsServlet extends ResourceServlet {
-  private static final long serialVersionUID = 1L;
-
-  private final Path zip;
-  private final Path fonts;
-
-  FontsServlet(Cache<Path, Resource> cache, Path buckOut)
-      throws IOException {
-    super(cache, true);
-    zip = getZipPath(buckOut);
-    if (zip == null || !Files.exists(zip)) {
-      fonts = null;
-    } else {
-      fonts = GerritLauncher
-          .newZipFileSystem(zip)
-          .getPath("/");
-    }
-  }
-
-  @Override
-  protected Path getResourcePath(String pathInfo) throws IOException {
-    if (fonts == null) {
-      throw new IOException("No fonts found: " + zip
-          + ". Run `buck build //polygerrit-ui:fonts`?");
-    }
-    return fonts.resolve(pathInfo);
-  }
-
-  private static Path getZipPath(Path buckOut) {
-    if (buckOut == null) {
-      return null;
-    }
-    return buckOut.resolve("gen")
-        .resolve("polygerrit-ui")
-        .resolve("fonts")
-        .resolve("fonts.zip");
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java
index 1984cbb..c36d257 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.httpd.raw;
 
-import com.google.gerrit.httpd.raw.BuckUtils.BuildFailureException;
+import com.google.gerrit.httpd.raw.BuildSystem.Label;
 import com.google.gwtexpui.linker.server.UserAgentRule;
 
 import java.io.File;
@@ -43,48 +43,40 @@
   private final UserAgentRule rule = new UserAgentRule();
   private final Set<String> uaInitialized = new HashSet<>();
   private final Path unpackedWar;
-  private final Path gen;
-  private final Path root;
+  private final BuildSystem builder;
 
-  private String lastTarget;
+  private String lastAgent;
   private long lastTime;
 
-  RecompileGwtUiFilter(Path buckOut, Path unpackedWar) {
+  RecompileGwtUiFilter(BuildSystem builder, Path unpackedWar) {
+    this.builder = builder;
     this.unpackedWar = unpackedWar;
-    gen = buckOut.resolve("gen");
-    root = buckOut.getParent();
   }
 
   @Override
   public void doFilter(ServletRequest request, ServletResponse res,
       FilterChain chain) throws IOException, ServletException {
-    String pkg = "gerrit-gwtui";
-    String target = "ui_" + rule.select((HttpServletRequest) request);
-    if (gwtuiRecompile || !uaInitialized.contains(target)) {
-      String rule = "//" + pkg + ":" + target;
-      // TODO(davido): instead of assuming specific Buck's internal
-      // target directory for gwt_binary() artifacts, ask Buck for
-      // the location of user agent permutation GWT zip, e. g.:
-      // $ buck targets --show_output //gerrit-gwtui:ui_safari \
-      //    | awk '{print $2}'
-      String child = String.format("%s/__gwt_binary_%s__", pkg, target);
-      File zip = gen.resolve(child).resolve(target + ".zip").toFile();
+    String agent = rule.select((HttpServletRequest) request);
+    if (unpackedWar != null
+        && (gwtuiRecompile || !uaInitialized.contains(agent))) {
+      Label label = builder.gwtZipLabel(agent);
+      File zip = builder.targetPath(label).toFile();
 
       synchronized (this) {
         try {
-          BuckUtils.build(root, gen, rule);
-        } catch (BuildFailureException e) {
-          BuckUtils.displayFailure(rule, e.why, (HttpServletResponse) res);
+          builder.build(label);
+        } catch (BuildSystem.BuildFailureException e) {
+          e.display(label.toString(), (HttpServletResponse) res);
           return;
         }
 
-        if (!target.equals(lastTarget) || lastTime != zip.lastModified()) {
-          lastTarget = target;
+        if (!agent.equals(lastAgent) || lastTime != zip.lastModified()) {
+          lastAgent = agent;
           lastTime = zip.lastModified();
           unpack(zip, unpackedWar.toFile());
         }
       }
-      uaInitialized.add(target);
+      uaInitialized.add(agent);
     }
     chain.doFilter(request, res);
   }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/StaticModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/StaticModule.java
index 31e337e..d1070fc 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -21,6 +21,7 @@
 
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.UiType;
 import com.google.gerrit.httpd.XsrfCookieFilter;
 import com.google.gerrit.httpd.raw.ResourceServlet.Resource;
@@ -220,8 +221,7 @@
       if (p.unpackedWar != null) {
         return p.unpackedWar.resolve(name);
       }
-      return p.buckOut.resolveSibling("gerrit-war").resolve("src")
-          .resolve("main").resolve("webapp").resolve(name);
+      return p.sourceRoot.resolve("gerrit-war/src/main/webapp/" + name);
     }
   }
 
@@ -232,7 +232,7 @@
           .with(Key.get(HttpServlet.class, Names.named(GWT_UI_SERVLET)));
       Paths p = getPaths();
       if (p.isDev()) {
-        filter("/").through(new RecompileGwtUiFilter(p.buckOut, p.unpackedWar));
+        filter("/").through(new RecompileGwtUiFilter(p.builder, p.unpackedWar));
       }
     }
 
@@ -282,27 +282,31 @@
 
     @Provides
     @Singleton
-    BowerComponentsServlet getBowerComponentsServlet(
+    BowerComponentsDevServlet getBowerComponentsServlet(
         @Named(CACHE) Cache<Path, Resource> cache) throws IOException {
-      return new BowerComponentsServlet(cache, getPaths().buckOut);
+      return getPaths().isDev()
+          ? new BowerComponentsDevServlet(cache, getPaths().builder)
+          : null;
     }
 
     @Provides
     @Singleton
-    FontsServlet getFontsServlet(
+    FontsDevServlet getFontsServlet(
         @Named(CACHE) Cache<Path, Resource> cache) throws IOException {
-      return new FontsServlet(cache, getPaths().buckOut);
+      return getPaths().isDev()
+          ? new FontsDevServlet(cache, getPaths().builder)
+          : null;
     }
 
     private Path polyGerritBasePath() {
       Paths p = getPaths();
       if (options.forcePolyGerritDev()) {
-        checkArgument(p.buckOut != null,
-            "no buck-out directory found for PolyGerrit developer mode");
+        checkArgument(p.sourceRoot != null,
+            "no source root directory found for PolyGerrit developer mode");
       }
 
       if (p.isDev()) {
-        return p.buckOut.getParent().resolve("polygerrit-ui").resolve("app");
+        return p.sourceRoot.resolve("polygerrit-ui").resolve("app");
       }
 
       return p.warFs != null
@@ -313,7 +317,8 @@
 
   private static class Paths {
     private final FileSystem warFs;
-    private final Path buckOut;
+    private final BuildSystem builder;
+    private final Path sourceRoot;
     private final Path unpackedWar;
     private final boolean development;
 
@@ -333,28 +338,42 @@
               .getParentFile()
               .getParentFile()
               .toURI());
-          buckOut = null;
+          sourceRoot = null;
           development = false;
+          builder = null;
           return;
         }
         warFs = getDistributionArchive(launcherLoadedFrom);
         if (warFs == null) {
-          buckOut = getDeveloperBuckOut();
           unpackedWar = makeWarTempDir();
           development = true;
         } else if (options.forcePolyGerritDev()) {
-          buckOut = getDeveloperBuckOut();
           unpackedWar = null;
           development = true;
         } else {
-          buckOut = null;
           unpackedWar = null;
           development = false;
+          sourceRoot = null;
+          builder = null;
+          return;
         }
       } catch (IOException e) {
         throw new ProvisionException(
             "Error initializing static content paths", e);
       }
+
+      sourceRoot = getSourseRootOrNull();
+      builder = GerritLauncher.isBazel()
+          ? new BazelBuild(sourceRoot)
+          : new BuckUtils(sourceRoot);
+    }
+
+    private static Path getSourseRootOrNull() {
+      try {
+        return GerritLauncher.resolveInSourceRoot(".");
+      } catch (FileNotFoundException e) {
+        return null;
+      }
     }
 
     private FileSystem getDistributionArchive(File war) throws IOException {
@@ -385,14 +404,6 @@
       return development;
     }
 
-    private Path getDeveloperBuckOut() {
-      try {
-        return GerritLauncher.getDeveloperBuckOut();
-      } catch (FileNotFoundException e) {
-        return null;
-      }
-    }
-
     private Path makeWarTempDir() {
       // Obtain our local temporary directory, but it comes back as a file
       // so we have to switch it to be a directory post creation.
@@ -430,16 +441,16 @@
     private final Paths paths;
     private final HttpServlet polyGerritIndex;
     private final PolyGerritUiServlet polygerritUI;
-    private final BowerComponentsServlet bowerComponentServlet;
-    private final FontsServlet fontServlet;
+    private final BowerComponentsDevServlet bowerComponentServlet;
+    private final FontsDevServlet fontServlet;
 
     @Inject
     PolyGerritFilter(GerritOptions options,
         Paths paths,
         @Named(POLYGERRIT_INDEX_SERVLET) HttpServlet polyGerritIndex,
         PolyGerritUiServlet polygerritUI,
-        BowerComponentsServlet bowerComponentServlet,
-        FontsServlet fontServlet) {
+        @Nullable BowerComponentsDevServlet bowerComponentServlet,
+        @Nullable FontsDevServlet fontServlet) {
       this.paths = paths;
       this.options = options;
       this.polyGerritIndex = polyGerritIndex;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
index 3f471bf..b39e2a2 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.common.data.ProjectAccess;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -76,13 +75,14 @@
   }
 
   @Override
-  protected ProjectAccess updateProjectConfig(CurrentUser user,
+  protected ProjectAccess updateProjectConfig(ProjectControl projectControl,
       ProjectConfig config, MetaDataUpdate md, boolean parentProjectUpdate)
       throws IOException, NoSuchProjectException, ConfigInvalidException {
     RevCommit commit = config.commit(md);
 
     gitRefUpdated.fire(config.getProject().getNameKey(), RefNames.REFS_CONFIG,
-        base, commit.getId(), user.asIdentifiedUser().getAccount());
+        base, commit.getId(),
+        projectControl.getUser().asIdentifiedUser().getAccount());
 
     projectCache.evict(config.getProject());
     return projectAccessFactory.create(projectName).call();
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
index bd88e6a..adfd528 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.httpd.rpc.project;
 
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.Maps;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GroupDescription;
@@ -206,8 +205,8 @@
 
     detail.setLocal(local);
     detail.setOwnerOf(ownerOf);
-    detail.setCanUpload(pc.isOwner()
-        || (metaConfigControl.isVisible() && metaConfigControl.canUpload()));
+    detail.setCanUpload(metaConfigControl.isVisible()
+        && (pc.isOwner() || metaConfigControl.canUpload()));
     detail.setConfigVisible(pc.isOwner() || metaConfigControl.isVisible());
     detail.setGroupInfo(buildGroupInfo(local));
     detail.setLabelTypes(pc.getLabelTypes());
@@ -216,10 +215,10 @@
   }
 
   private List<WebLinkInfoCommon> getConfigFileLogLinks(String projectName) {
-    FluentIterable<WebLinkInfoCommon> links =
-        webLinks.getFileHistoryLinksCommon(projectName, RefNames.REFS_CONFIG,
+    List<WebLinkInfoCommon> links =
+        webLinks.getFileHistoryLinks(projectName, RefNames.REFS_CONFIG,
             ProjectConfig.PROJECT_CONFIG);
-    return links.isEmpty() ? null : links.toList();
+    return links.isEmpty() ? null : links;
   }
 
   private Map<AccountGroup.UUID, GroupInfo> buildGroupInfo(List<AccessSection> local) {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
index 111dfc9..4c7d257 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
@@ -31,7 +31,6 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.httpd.rpc.Handler;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -163,17 +162,17 @@
         md.setMessage("Modify access rules\n");
       }
 
-      return updateProjectConfig(projectControl.getUser(), config, md,
+      return updateProjectConfig(projectControl, config, md,
           parentProjectUpdate);
     } catch (RepositoryNotFoundException notFound) {
       throw new NoSuchProjectException(projectName);
     }
   }
 
-  protected abstract T updateProjectConfig(CurrentUser user,
+  protected abstract T updateProjectConfig(ProjectControl projectControl,
       ProjectConfig config, MetaDataUpdate md, boolean parentProjectUpdate)
       throws IOException, NoSuchProjectException, ConfigInvalidException,
-      OrmException;
+      OrmException, PermissionDeniedException;
 
   private void replace(ProjectConfig config, Set<String> toDelete,
       AccessSection section) throws NoSuchGroupException {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
index 9260e01..966cd88 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.errors.PermissionDeniedException;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -27,7 +28,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.change.ChangeInserter;
@@ -43,6 +43,7 @@
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.RefControl;
 import com.google.gerrit.server.project.SetParent;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -106,9 +107,20 @@
   }
 
   @Override
-  protected Change.Id updateProjectConfig(CurrentUser user,
+  protected Change.Id updateProjectConfig(ProjectControl projectControl,
       ProjectConfig config, MetaDataUpdate md, boolean parentProjectUpdate)
-      throws IOException, OrmException {
+          throws IOException, OrmException, PermissionDeniedException {
+    RefControl refsMetaConfigControl =
+        projectControl.controlForRef(RefNames.REFS_CONFIG);
+    if (!refsMetaConfigControl.isVisible()) {
+      throw new PermissionDeniedException(
+          RefNames.REFS_CONFIG + " not visible");
+    }
+    if (!projectControl.isOwner() && !refsMetaConfigControl.canUpload()) {
+      throw new PermissionDeniedException(
+          "cannot upload to " + RefNames.REFS_CONFIG);
+    }
+
     md.setInsertChangeId(true);
     Change.Id changeId = new Change.Id(seq.nextChangeId());
     RevCommit commit =
@@ -120,9 +132,9 @@
 
     try (RevWalk rw = new RevWalk(md.getRepository());
         ObjectInserter objInserter = md.getRepository().newObjectInserter();
-        BatchUpdate bu = updateFactory.create(
-          db, config.getProject().getNameKey(), user,
-          TimeUtil.nowTs())) {
+        BatchUpdate bu =
+            updateFactory.create(db, config.getProject().getNameKey(),
+                projectControl.getUser(), TimeUtil.nowTs())) {
       bu.setRepository(md.getRepository(), rw, objInserter);
       bu.insertChange(
           changeInserterFactory.create(changeId, commit, RefNames.REFS_CONFIG)
diff --git a/gerrit-index/BUCK b/gerrit-index/BUCK
deleted file mode 100644
index ea97f88..0000000
--- a/gerrit-index/BUCK
+++ /dev/null
@@ -1,13 +0,0 @@
-java_library(
-  name = 'index',
-  srcs = glob(['src/main/java/**/*.java']),
-  deps = [
-    '//gerrit-extension-api:api',
-    '//gerrit-server:server',
-    '//gerrit-patch-jgit:server',
-    '//lib/guice:guice',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib:guava',
-  ],
-  visibility = ['PUBLIC'],
-)
diff --git a/gerrit-index/BUILD b/gerrit-index/BUILD
deleted file mode 100644
index 119d5c4..0000000
--- a/gerrit-index/BUILD
+++ /dev/null
@@ -1,13 +0,0 @@
-java_library(
-  name = 'index',
-  srcs = glob(['src/main/java/**/*.java']),
-  deps = [
-    '//gerrit-extension-api:api',
-    '//gerrit-server:server',
-    '//gerrit-patch-jgit:server',
-    '//lib/guice:guice',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib:guava',
-  ],
-  visibility = ['//visibility:public'],
-)
diff --git a/gerrit-launcher/BUILD b/gerrit-launcher/BUILD
index ced3447..cf1e788 100644
--- a/gerrit-launcher/BUILD
+++ b/gerrit-launcher/BUILD
@@ -3,5 +3,17 @@
 java_library(
   name = 'launcher',
   srcs = ['src/main/java/com/google/gerrit/launcher/GerritLauncher.java'],
+  resources = [':workspace-root.txt'],
+  visibility = ['//visibility:public'],
+)
+
+# The root of the workspace is non-hermetic, but we need it for
+# on-the-fly GWT recompiles and PolyGerrit updates.
+genrule(
+  name = 'gen_root',
+  stamp = 1,
+  cmd = ("cat bazel-out/stable-status.txt | " +
+    "grep STABLE_WORKSPACE_ROOT | cut -d ' ' -f 2 > $@"),
+  outs = ['workspace-root.txt'],
   visibility = ['//visibility:public'],
 )
diff --git a/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java b/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java
index a272864..32e4dd8 100644
--- a/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java
+++ b/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.launcher;
 
+import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.concurrent.TimeUnit.DAYS;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 
@@ -42,6 +43,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Scanner;
 import java.util.SortedMap;
 import java.util.TreeMap;
 import java.util.jar.Attributes;
@@ -615,25 +617,77 @@
   /**
    * Locate the path of the {@code eclipse-out} directory in a source tree.
    *
+   * @return local path of the {@code eclipse-out} directory in a source tree.
    * @throws FileNotFoundException if the directory cannot be found.
    */
   public static Path getDeveloperEclipseOut() throws FileNotFoundException {
     return resolveInSourceRoot("eclipse-out");
   }
 
-  /**
-   * Locate the path of the {@code buck-out} directory in a source tree.
-   *
-   * @throws FileNotFoundException if the directory cannot be found.
-   */
-  public static Path getDeveloperBuckOut() throws FileNotFoundException {
-    return resolveInSourceRoot("buck-out");
+  static String SOURCE_ROOT_RESOURCE = "/gerrit-launcher/workspace-root.txt";
+  static String PRIMARY_BUILD_TOOL = ".primary_build_tool";
+
+  /** returns whether we're running out of a bazel build. */
+  public static boolean isBazel() {
+    Class<GerritLauncher> self = GerritLauncher.class;
+    URL rootURL = self.getResource(SOURCE_ROOT_RESOURCE);
+    if (rootURL != null) {
+      return true;
+    }
+
+    Path p = null;
+    try {
+      p = resolveInSourceRoot("eclipse-out");
+      if (!Files.exists(p)) {
+        p = resolveInSourceRoot("bazel-out");
+      }
+      Path path = p.getParent().resolve(PRIMARY_BUILD_TOOL);
+      if (Files.exists(path)) {
+        String content = new String(Files.readAllBytes(path));
+        if (content.toLowerCase().contains("bazel")) {
+          return true;
+        }
+      }
+    } catch (IOException e) {
+      // Ignore
+    }
+
+    // Not Bazel then
+    return false;
   }
 
-  private static Path resolveInSourceRoot(String name)
+  /**
+   * Locate a path in the source tree.
+   *
+   * @return local path of the {@code name} directory in a source tree.
+   * @throws FileNotFoundException if the directory cannot be found.
+   */
+  public static Path resolveInSourceRoot(String name)
       throws FileNotFoundException {
+
     // Find ourselves in the classpath, as a loose class file or jar.
     Class<GerritLauncher> self = GerritLauncher.class;
+
+    // If the build system provides us with a source root, use that.
+    try (InputStream stream = self.getResourceAsStream(SOURCE_ROOT_RESOURCE)) {
+      System.err.println("URL: " + stream);
+      if (stream != null) {
+        try (Scanner scan =
+            new Scanner(stream, UTF_8.name()).useDelimiter("\n")) {
+          if (scan.hasNext()) {
+            Path p = Paths.get(scan.next());
+            if (!Files.exists(p)) {
+              throw new FileNotFoundException(
+                  "source root not found: " + p);
+            }
+            return p;
+          }
+        }
+      }
+    } catch (IOException e) {
+      // not Bazel, then.
+    }
+
     URL u = self.getResource(self.getSimpleName() + ".class");
     if (u == null) {
       throw new FileNotFoundException("Cannot find class " + self.getName());
@@ -654,7 +708,8 @@
 
     // Pop up to the top-level source folder by looking for .buckconfig.
     Path dir = Paths.get(u.getPath());
-    while (!Files.isRegularFile(dir.resolve(".buckconfig"))) {
+    while (!Files.isRegularFile(dir.resolve(".buckconfig"))
+        && !Files.isRegularFile(dir.resolve("WORKSPACE"))) {
       Path parent = dir.getParent();
       if (parent == null) {
         throw new FileNotFoundException("Cannot find source root from " + u);
@@ -672,7 +727,7 @@
 
   private static ClassLoader useDevClasspath()
       throws MalformedURLException, FileNotFoundException {
-    Path out = getDeveloperEclipseOut();
+    Path out = resolveInSourceRoot("eclipse-out");
     List<URL> dirs = new ArrayList<>();
     dirs.add(out.resolve("classes").toUri().toURL());
     ClassLoader cl = GerritLauncher.class.getClassLoader();
diff --git a/gerrit-lucene/BUCK b/gerrit-lucene/BUCK
index f4f097c..771a021 100644
--- a/gerrit-lucene/BUCK
+++ b/gerrit-lucene/BUCK
@@ -27,7 +27,6 @@
     '//gerrit-extension-api:api',
     '//gerrit-reviewdb:server',
     '//gerrit-server:server',
-    '//gerrit-index:index',
     '//lib:guava',
     '//lib:gwtorm',
     '//lib/guice:guice',
diff --git a/gerrit-lucene/BUILD b/gerrit-lucene/BUILD
index de010eb..2f1cba7 100644
--- a/gerrit-lucene/BUILD
+++ b/gerrit-lucene/BUILD
@@ -25,7 +25,6 @@
     '//gerrit-common:annotations',
     '//gerrit-common:server',
     '//gerrit-extension-api:api',
-    '//gerrit-index:index',
     '//gerrit-reviewdb:server',
     '//gerrit-server:server',
     '//lib:guava',
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index e869afb..6237a61 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -25,12 +25,12 @@
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
-import com.google.gerrit.index.IndexUtils;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.FieldDef;
 import com.google.gerrit.server.index.FieldDef.FillArgs;
 import com.google.gerrit.server.index.FieldType;
 import com.google.gerrit.server.index.Index;
+import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.Schema;
 import com.google.gerrit.server.index.Schema.Values;
 
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneAccountIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneAccountIndex.java
index 78c0185..87f1608 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneAccountIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneAccountIndex.java
@@ -16,13 +16,12 @@
 
 import static com.google.gerrit.server.index.account.AccountField.ID;
 
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Sets;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.QueryOptions;
 import com.google.gerrit.server.index.Schema;
 import com.google.gerrit.server.index.account.AccountIndex;
@@ -56,7 +55,6 @@
 import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
-import java.util.Set;
 import java.util.concurrent.ExecutionException;
 
 public class LuceneAccountIndex
@@ -164,7 +162,7 @@
         List<AccountState> result = new ArrayList<>(docs.scoreDocs.length);
         for (int i = opts.start(); i < docs.scoreDocs.length; i++) {
           ScoreDoc sd = docs.scoreDocs[i];
-          Document doc = searcher.doc(sd.doc, fields(opts));
+          Document doc = searcher.doc(sd.doc, IndexUtils.accountFields(opts));
           result.add(toAccountState(doc));
         }
         final List<AccountState> r = Collections.unmodifiableList(result);
@@ -198,13 +196,6 @@
     }
   }
 
-  private Set<String> fields(QueryOptions opts) {
-    Set<String> fs = opts.fields();
-    return fs.contains(ID.getName())
-        ? fs
-        : Sets.union(fs, ImmutableSet.of(ID.getName()));
-  }
-
   private AccountState toAccountState(Document doc) {
     Account.Id id =
         new Account.Id(doc.getField(ID.getName()).numericValue().intValue());
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 80adbb9..e6d395c 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -16,12 +16,12 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName;
-import static com.google.gerrit.lucene.LuceneVersionManager.CHANGES_PREFIX;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
 import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Throwables;
 import com.google.common.collect.ArrayListMultimap;
@@ -34,7 +34,6 @@
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.gerrit.index.IndexUtils;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -45,6 +44,7 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.FieldDef.FillArgs;
 import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.QueryOptions;
 import com.google.gerrit.server.index.Schema;
 import com.google.gerrit.server.index.change.ChangeField;
@@ -109,20 +109,21 @@
   private static final Logger log =
       LoggerFactory.getLogger(LuceneChangeIndex.class);
 
-  public static final String CHANGES_OPEN = "open";
-  public static final String CHANGES_CLOSED = "closed";
+  static final String UPDATED_SORT_FIELD = sortFieldName(ChangeField.UPDATED);
+  static final String ID_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID);
 
-  static final String UPDATED_SORT_FIELD =
-      sortFieldName(ChangeField.UPDATED);
-  static final String ID_SORT_FIELD =
-      sortFieldName(ChangeField.LEGACY_ID);
-
+  private static final String CHANGES_PREFIX = "changes_";
+  private static final String CHANGES_OPEN = "open";
+  private static final String CHANGES_CLOSED = "closed";
   private static final String ADDED_FIELD = ChangeField.ADDED.getName();
   private static final String APPROVAL_FIELD = ChangeField.APPROVAL.getName();
   private static final String CHANGE_FIELD = ChangeField.CHANGE.getName();
   private static final String DELETED_FIELD = ChangeField.DELETED.getName();
   private static final String MERGEABLE_FIELD = ChangeField.MERGEABLE.getName();
   private static final String PATCH_SET_FIELD = ChangeField.PATCH_SET.getName();
+  private static final String REF_STATE_FIELD = ChangeField.REF_STATE.getName();
+  private static final String REF_STATE_PATTERN_FIELD =
+      ChangeField.REF_STATE_PATTERN.getName();
   private static final String REVIEWEDBY_FIELD =
       ChangeField.REVIEWEDBY.getName();
   private static final String REVIEWER_FIELD = ChangeField.REVIEWER.getName();
@@ -322,7 +323,7 @@
         throw new OrmException("interrupted");
       }
 
-      final Set<String> fields = IndexUtils.fields(opts);
+      final Set<String> fields = IndexUtils.changeFields(opts);
       return new ChangeDataResults(
           executor.submit(new Callable<List<Document>>() {
             @Override
@@ -475,6 +476,12 @@
         ChangeField.SUBMIT_RULE_OPTIONS_STRICT, cd);
     decodeSubmitRecords(doc, SUBMIT_RECORD_LENIENT_FIELD,
         ChangeField.SUBMIT_RULE_OPTIONS_LENIENT, cd);
+    if (fields.contains(REF_STATE_FIELD)) {
+      decodeRefStates(doc, cd);
+    }
+    if (fields.contains(REF_STATE_PATTERN_FIELD)) {
+      decodeRefStatePatterns(doc, cd);
+    }
     return cd;
   }
 
@@ -575,6 +582,16 @@
         opts, cd);
   }
 
+  private void decodeRefStates(Multimap<String, IndexableField> doc,
+      ChangeData cd) {
+    cd.setRefStates(copyAsBytes(doc.get(REF_STATE_FIELD)));
+  }
+
+  private void decodeRefStatePatterns(Multimap<String, IndexableField> doc,
+      ChangeData cd) {
+    cd.setRefStatePatterns(copyAsBytes(doc.get(REF_STATE_PATTERN_FIELD)));
+  }
+
   private static <T> List<T> decodeProtos(Multimap<String, IndexableField> doc,
       String fieldName, ProtobufCodec<T> codec) {
     Collection<IndexableField> fields = doc.get(fieldName);
@@ -589,4 +606,16 @@
     }
     return result;
   }
+
+  private static List<byte[]> copyAsBytes(Collection<IndexableField> fields) {
+    return fields.stream()
+        .map(
+            f -> {
+              BytesRef ref = f.binaryValue();
+              byte[] b = new byte[ref.length];
+              System.arraycopy(ref.bytes, ref.offset, b, 0, ref.length);
+              return b;
+            })
+        .collect(toList());
+  }
 }
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
index 58890176..d23c5bb 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
@@ -15,11 +15,11 @@
 package com.google.gerrit.lucene;
 
 import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.index.SingleVersionModule;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.IndexConfig;
 import com.google.gerrit.server.index.IndexModule;
+import com.google.gerrit.server.index.SingleVersionModule;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.inject.Provides;
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java
index 2f871fc..e95a1fb 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java
@@ -20,9 +20,9 @@
 import com.google.common.collect.Maps;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.index.GerritIndexStatus;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.GerritIndexStatus;
 import com.google.gerrit.server.index.Index;
 import com.google.gerrit.server.index.IndexCollection;
 import com.google.gerrit.server.index.IndexDefinition;
@@ -52,8 +52,6 @@
   private static final Logger log = LoggerFactory
       .getLogger(LuceneVersionManager.class);
 
-  static final String CHANGES_PREFIX = "changes_";
-
   private static class Version<V> {
     private final Schema<V> schema;
     private final int version;
diff --git a/gerrit-patch-jgit/BUILD b/gerrit-patch-jgit/BUILD
index 98cd32a..f908d83 100644
--- a/gerrit-patch-jgit/BUILD
+++ b/gerrit-patch-jgit/BUILD
@@ -27,12 +27,12 @@
 genrule2(
   name = 'jgit_edit_src',
   cmd = ' && '.join([
-    'unzip -qd $$TMP $(location @jgit_src//file) ' +
+    'unzip -qd $$TMP $(location @jgit//jar:src) ' +
       'org/eclipse/jgit/diff/Edit.java',
     'cd $$TMP',
     'zip -Dq $$ROOT/$@ org/eclipse/jgit/diff/Edit.java',
   ]),
-  tools = ['@jgit_src//file'],
+  tools = ['@jgit//jar:src'],
   outs = [ 'edit.srcjar' ],
 )
 
diff --git a/gerrit-pgm/BUCK b/gerrit-pgm/BUCK
index d5abf99..1d0c752 100644
--- a/gerrit-pgm/BUCK
+++ b/gerrit-pgm/BUCK
@@ -47,7 +47,6 @@
     ':init-api',
     ':util',
     '//gerrit-common:annotations',
-    '//gerrit-index:index',
     '//lib:args4j',
     '//lib:derby',
     '//lib:gwtjsonrpc',
diff --git a/gerrit-pgm/BUILD b/gerrit-pgm/BUILD
index 4f2b609..eef7dcb 100644
--- a/gerrit-pgm/BUILD
+++ b/gerrit-pgm/BUILD
@@ -45,7 +45,6 @@
     ':init-api',
     ':util',
     '//gerrit-common:annotations',
-    '//gerrit-index:index',
     '//gerrit-launcher:launcher', # We want this dep to be provided_deps
     '//gerrit-lucene:lucene',
     '//lib:args4j',
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java
index c8d8edb..c9a9b5c 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java
@@ -16,7 +16,6 @@
 
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
-import com.google.gerrit.index.IndexUtils;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
@@ -24,6 +23,7 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.IndexModule.IndexType;
+import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.SchemaDefinitions;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/ConsoleUI.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
index e210d5b..78ea5a0 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
@@ -87,6 +87,12 @@
   /** Prompt the user for a password, returning the string; null if blank. */
   public abstract String password(String fmt, Object... args);
 
+  /** Display an error message on the system stderr. */
+  public void error(String format, Object... args) {
+    System.err.println(String.format(format, args));
+    System.err.flush();
+  }
+
   /** Prompt the user to make a choice from an enumeration's values. */
   public abstract <T extends Enum<?>> T readEnum(T def, String fmt,
       Object... args);
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/LogFileCompressor.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/LogFileCompressor.java
index 262997b..2670407 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/LogFileCompressor.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/LogFileCompressor.java
@@ -24,8 +24,6 @@
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.Inject;
 
-import org.joda.time.DateTime;
-import org.joda.time.Duration;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -35,6 +33,8 @@
 import java.nio.file.DirectoryStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.time.LocalDate;
+import java.time.ZoneId;
 import java.util.zip.GZIPOutputStream;
 
 /** Compresses the old error logs. */
@@ -64,12 +64,12 @@
     public void start() {
       //compress log once and then schedule compression every day at 11:00pm
       queue.getDefaultQueue().execute(compressor);
-      DateTime now = DateTime.now();
-      long milliSecondsUntil11am =
-          new Duration(now, now.withTimeAtStartOfDay().plusHours(23))
-              .getMillis();
+      ZoneId zone = ZoneId.systemDefault();
+      LocalDate now = LocalDate.now(zone);
+      long milliSecondsUntil11pm = now.atStartOfDay(zone)
+          .plusHours(23).toInstant().toEpochMilli();
       queue.getDefaultQueue().scheduleAtFixedRate(compressor,
-          milliSecondsUntil11am, HOURS.toMillis(24), MILLISECONDS);
+          milliSecondsUntil11pm, HOURS.toMillis(24), MILLISECONDS);
     }
 
     @Override
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteProgram.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteProgram.java
index a2e0450..f0cc5c5 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteProgram.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteProgram.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
+import com.google.gerrit.server.LibModuleLoader;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
@@ -180,6 +181,7 @@
     modules.add(new SchemaModule());
     modules.add(cfgInjector.getInstance(GitRepositoryManagerModule.class));
     modules.add(new ConfigNotesMigration.Module());
+    modules.addAll(LibModuleLoader.loadModules(cfgInjector));
 
     try {
       return Guice.createInjector(PRODUCTION, modules);
diff --git a/gerrit-plugin-gwtui/BUILD b/gerrit-plugin-gwtui/BUILD
index ac1909f..01d9385 100644
--- a/gerrit-plugin-gwtui/BUILD
+++ b/gerrit-plugin-gwtui/BUILD
@@ -2,7 +2,7 @@
 load('//tools/bzl:java.bzl', 'java_library2')
 
 SRCS = glob(['src/main/java/com/google/gerrit/**/*.java'])
-DEPS = ['//lib/gwt:user']
+DEPS = ['//lib/gwt:user-neverlink']
 
 java_binary(
   name = 'gwtui-api',
@@ -18,7 +18,30 @@
   srcs = SRCS,
   resources = glob(['src/main/**/*']),
   exported_deps = ['//gerrit-gwtui-common:client-lib'],
-  deps = DEPS + ['//lib/gwt:dev'], # we want this to be exported deps
+  deps = DEPS + [
+    '//gerrit-common:libclient-src.jar',
+    '//gerrit-extension-api:libclient-src.jar',
+    '//gerrit-gwtexpui:libClippy-src.jar',
+    '//gerrit-gwtexpui:libGlobalKey-src.jar',
+    '//gerrit-gwtexpui:libProgress-src.jar',
+    '//gerrit-gwtexpui:libSafeHtml-src.jar',
+    '//gerrit-gwtexpui:libUserAgent-src.jar',
+    '//gerrit-gwtui-common:libclient-src.jar',
+    '//gerrit-patch-jgit:libclient-src.jar',
+    '//gerrit-patch-jgit:libEdit-src.jar',
+    '//gerrit-prettify:libclient-src.jar',
+    '//gerrit-reviewdb:libclient-src.jar',
+    '//lib/gwt:dev-neverlink',
+  ],
+)
+
+java_library2(
+  name = 'gwtui-api-lib-neverlink',
+  srcs = SRCS,
+  resources = glob(['src/main/**/*']),
+  exported_deps = ['//gerrit-gwtui-common:client-lib'],
+  neverlink = 1, # we want this to be exported deps
+  deps = DEPS + ['//lib/gwt:dev'],
 )
 
 java_binary(
diff --git a/gerrit-prettify/BUILD b/gerrit-prettify/BUILD
index b8d4dd6..c21a6f4 100644
--- a/gerrit-prettify/BUILD
+++ b/gerrit-prettify/BUILD
@@ -8,7 +8,7 @@
     SRC + 'common/**/*.java',
   ]),
   gwt_xml = SRC + 'PrettyFormatter.gwt.xml',
-  deps = ['//lib/gwt:user'],
+  deps = ['//lib/gwt:user-neverlink'],
   exported_deps = [
     '//gerrit-extension-api:client',
     '//gerrit-gwtexpui:SafeHtml',
diff --git a/gerrit-reviewdb/BUCK b/gerrit-reviewdb/BUCK
index a5fb1f5..532ce801 100644
--- a/gerrit-reviewdb/BUCK
+++ b/gerrit-reviewdb/BUCK
@@ -30,6 +30,7 @@
   srcs = glob([TESTS + 'client/**/*.java']),
   deps = [
     ':client',
+    '//gerrit-server:testutil',
     '//lib:gwtorm',
     '//lib:truth',
   ],
diff --git a/gerrit-reviewdb/BUILD b/gerrit-reviewdb/BUILD
index a4144ec..f9a7235 100644
--- a/gerrit-reviewdb/BUILD
+++ b/gerrit-reviewdb/BUILD
@@ -1,3 +1,7 @@
+package(
+  default_visibility=["//visibility:public"]
+)
+
 load('//tools/bzl:gwt.bzl', 'gwt_module')
 load('//tools/bzl:junit.bzl', 'junit_tests')
 
@@ -33,6 +37,7 @@
   srcs = glob([TESTS + 'client/**/*.java']),
   deps = [
     ':client',
+    '//gerrit-server:testutil',
     '//lib:gwtorm',
     '//lib:truth',
   ],
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountSshKey.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountSshKey.java
index f63c618..78aef91 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountSshKey.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountSshKey.java
@@ -67,7 +67,7 @@
 
   public AccountSshKey(final AccountSshKey.Id i, final String pub) {
     id = i;
-    sshPublicKey = pub;
+    sshPublicKey = pub.replace("\n", "").replace("\r", "");
     valid = id.isValid();
   }
 
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
index 0f3d45c..fbaabc6 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
@@ -143,6 +143,9 @@
     }
 
     public static Id fromRef(String ref) {
+      if (RefNames.isRefsEdit(ref)) {
+        return fromEditRefPart(ref);
+      }
       int cs = startIndex(ref);
       if (cs < 0) {
         return null;
@@ -156,6 +159,42 @@
       return null;
     }
 
+    public static Id fromAllUsersRef(String ref) {
+      if (ref == null) {
+        return null;
+      }
+      String prefix;
+      if (ref.startsWith(RefNames.REFS_STARRED_CHANGES)) {
+        prefix = RefNames.REFS_STARRED_CHANGES;
+      } else if (ref.startsWith(RefNames.REFS_DRAFT_COMMENTS)) {
+        prefix = RefNames.REFS_DRAFT_COMMENTS;
+      } else {
+        return null;
+      }
+      int cs = startIndex(ref, prefix);
+      if (cs < 0) {
+        return null;
+      }
+      int ce = nextNonDigit(ref, cs);
+      if (ce < ref.length() && ref.charAt(ce) == '/'
+          && isNumeric(ref, ce + 1)) {
+        return new Change.Id(Integer.parseInt(ref.substring(cs, ce)));
+      }
+      return null;
+    }
+
+    private static boolean isNumeric(String s, int off) {
+      if (off >= s.length()) {
+        return false;
+      }
+      for (int i = off; i < s.length(); i++) {
+        if (!Character.isDigit(s.charAt(i))) {
+          return false;
+        }
+      }
+      return true;
+    }
+
     public static Id fromEditRefPart(String ref) {
       int startChangeId = ref.indexOf(RefNames.EDIT_PREFIX) +
           RefNames.EDIT_PREFIX.length();
@@ -173,12 +212,16 @@
     }
 
     static int startIndex(String ref) {
-      if (ref == null || !ref.startsWith(REFS_CHANGES)) {
+      return startIndex(ref, REFS_CHANGES);
+    }
+
+    static int startIndex(String ref, String expectedPrefix) {
+      if (ref == null || !ref.startsWith(expectedPrefix)) {
         return -1;
       }
 
       // Last 2 digits.
-      int ls = REFS_CHANGES.length();
+      int ls = expectedPrefix.length();
       int le = nextNonDigit(ref, ls);
       if (le - ls != 2 || le >= ref.length() || ref.charAt(le) != '/') {
         return -1;
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
index c3aff5b..c7c870e 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
@@ -26,6 +26,8 @@
 
   public static final String REFS_CHANGES = "refs/changes/";
 
+  public static final String REFS_META = "refs/meta/";
+
   /** Note tree listing commits we refuse {@code refs/meta/reject-commits} */
   public static final String REFS_REJECT_COMMITS = "refs/meta/reject-commits";
 
@@ -193,7 +195,8 @@
   }
 
   public static boolean isRefsEdit(String ref) {
-    return ref.startsWith(REFS_USERS) && ref.contains(EDIT_PREFIX);
+    return ref != null && ref.startsWith(REFS_USERS)
+        && ref.contains(EDIT_PREFIX);
   }
 
   public static boolean isRefsUsers(String ref) {
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/SystemConfig.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/SystemConfig.java
index 0f8d005..8b7a661 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/SystemConfig.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/SystemConfig.java
@@ -55,7 +55,7 @@
   /**
    * Local filesystem location of header/footer/CSS configuration files
    */
-  @Column(id = 3, notNull = false)
+  @Column(id = 3, notNull = false, length = Integer.MAX_VALUE)
   public transient String sitePath;
 
 
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountSshKeyTest.java b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountSshKeyTest.java
index 139d360..07c00b9 100644
--- a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountSshKeyTest.java
+++ b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountSshKeyTest.java
@@ -25,6 +25,12 @@
       + "vf8IZixgjCmiBhaL2gt3wff6pP+NXJpTSA4aeWE5DfNK5tZlxlSxqkKOS8JRSUeNQov5T"
       + "w== john.doe@example.com";
 
+  private static final String KEY_WITH_NEWLINES =
+      "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCgug5VyMXQGnem2H1KVC4/HcRcD4zzBqS\n"
+      + "uJBRWVonSSoz3RoAZ7bWXCVVGwchtXwUURD689wFYdiPecOrWOUgeeyRq754YWRhU+W28\n"
+      + "vf8IZixgjCmiBhaL2gt3wff6pP+NXJpTSA4aeWE5DfNK5tZlxlSxqkKOS8JRSUeNQov5T\n"
+      + "w== john.doe@example.com";
+
   private final Account.Id accountId = new Account.Id(1);
 
   @Test
@@ -47,4 +53,14 @@
     assertThat(key.getEncodedKey()).isEqualTo(KEY.split(" ")[1]);
     assertThat(key.getComment()).isEqualTo(KEY.split(" ")[2]);
   }
+
+  @Test
+  public void testKeyWithNewLines() throws Exception {
+    AccountSshKey key = new AccountSshKey(
+        new AccountSshKey.Id(accountId, 1), KEY_WITH_NEWLINES);
+    assertThat(key.getSshPublicKey()).isEqualTo(KEY);
+    assertThat(key.getAlgorithm()).isEqualTo(KEY.split(" ")[0]);
+    assertThat(key.getEncodedKey()).isEqualTo(KEY.split(" ")[1]);
+    assertThat(key.getComment()).isEqualTo(KEY.split(" ")[2]);
+  }
 }
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/ChangeTest.java b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/ChangeTest.java
index cf2d289..2aa863e 100644
--- a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/ChangeTest.java
+++ b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/ChangeTest.java
@@ -64,6 +64,15 @@
   }
 
   @Test
+  public void parseEditRefNames() {
+    assertRef(5, "refs/users/34/1234/edit-5/1");
+    assertRef(5, "refs/users/34/1234/edit-5");
+    assertNotRef("refs/changes/34/1234/edit-5/1");
+    assertNotRef("refs/users/34/1234/EDIT-5/1");
+    assertNotRef("refs/users/34/1234");
+  }
+
+  @Test
   public void parseChangeMetaRefNames() {
     assertRef(1, "refs/changes/01/1/meta");
     assertRef(1234, "refs/changes/34/1234/meta");
@@ -74,6 +83,44 @@
   }
 
   @Test
+  public void parseRobotCommentRefNames() {
+    assertRef(1, "refs/changes/01/1/robot-comments");
+    assertRef(1234, "refs/changes/34/1234/robot-comments");
+
+    assertNotRef("refs/changes/01/1/robot-comment");
+    assertNotRef("refs/changes/01/1/ROBOT-COMMENTS");
+    assertNotRef("refs/changes/01/1/1/robot-comments");
+  }
+
+  @Test
+  public void parseStarredChangesRefNames() {
+    assertAllUsersRef(1, "refs/starred-changes/01/1/1001");
+    assertAllUsersRef(1234, "refs/starred-changes/34/1234/1001");
+
+    assertNotRef("refs/starred-changes/01/1/1001");
+    assertNotAllUsersRef(null);
+    assertNotAllUsersRef("refs/starred-changes/01/1/1xx1");
+    assertNotAllUsersRef("refs/starred-changes/01/1/");
+    assertNotAllUsersRef("refs/starred-changes/01/1");
+    assertNotAllUsersRef("refs/starred-changes/35/1234/1001");
+    assertNotAllUsersRef("refs/starred-changeS/01/1/1001");
+  }
+
+  @Test
+  public void parseDraftRefNames() {
+    assertAllUsersRef(1, "refs/draft-comments/01/1/1001");
+    assertAllUsersRef(1234, "refs/draft-comments/34/1234/1001");
+
+    assertNotRef("refs/draft-comments/01/1/1001");
+    assertNotAllUsersRef(null);
+    assertNotAllUsersRef("refs/draft-comments/01/1/1xx1");
+    assertNotAllUsersRef("refs/draft-comments/01/1/");
+    assertNotAllUsersRef("refs/draft-comments/01/1");
+    assertNotAllUsersRef("refs/draft-comments/35/1234/1001");
+    assertNotAllUsersRef("refs/draft-commentS/01/1/1001");
+  }
+
+  @Test
   public void toRefPrefix() {
     assertThat(new Change.Id(1).toRefPrefix())
         .isEqualTo("refs/changes/01/1/");
@@ -110,6 +157,15 @@
     assertThat(Change.Id.fromRef(refName)).isNull();
   }
 
+  private static void assertAllUsersRef(int changeId, String refName) {
+    assertThat(Change.Id.fromAllUsersRef(refName))
+        .isEqualTo(new Change.Id(changeId));
+  }
+
+  private static void assertNotAllUsersRef(String refName) {
+    assertThat(Change.Id.fromAllUsersRef(refName)).isNull();
+  }
+
   private static void assertRefPart(int changeId, String refName) {
     assertEquals(new Change.Id(changeId), Change.Id.fromRefPart(refName));
   }
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/PatchSetApprovalTest.java b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/PatchSetApprovalTest.java
index eba08c8..008c77f 100644
--- a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/PatchSetApprovalTest.java
+++ b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/PatchSetApprovalTest.java
@@ -16,19 +16,14 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gwtorm.client.KeyUtil;
-import com.google.gwtorm.server.StandardKeyEncoder;
+import com.google.gerrit.testutil.GerritBaseTests;
 
 import org.junit.Test;
 
 import java.util.HashMap;
 import java.util.Map;
 
-public class PatchSetApprovalTest {
-  static {
-    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
-  }
-
+public class PatchSetApprovalTest extends GerritBaseTests {
   @Test
   public void keyEquality() {
     PatchSetApproval.Key k1 = new PatchSetApproval.Key(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java
index 263eca4..55c7e21 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -44,31 +44,33 @@
  */
 @Singleton
 public class ChangeMessagesUtil {
-  public final static String TAG_ABANDON =
+  public static final String TAG_ABANDON =
       "autogenerated:gerrit:abandon";
-  public final static String TAG_CHERRY_PICK_CHANGE =
+  public static final String TAG_CHERRY_PICK_CHANGE =
       "autogenerated:gerrit:cherryPickChange";
-  public final static String TAG_DELETE_ASSIGNEE =
+  public static final String TAG_DELETE_ASSIGNEE =
       "autogenerated:gerrit:deleteAssignee";
-  public final static String TAG_DELETE_REVIEWER =
+  public static final String TAG_DELETE_REVIEWER =
       "autogenerated:gerrit:deleteReviewer";
-  public final static String TAG_DELETE_VOTE =
+  public static final String TAG_DELETE_VOTE =
       "autogenerated:gerrit:deleteVote";
-  public final static String TAG_MERGED =
+  public static final String TAG_MERGED =
       "autogenerated:gerrit:merged";
-  public final static String TAG_MOVE =
+  public static final String TAG_MOVE =
       "autogenerated:gerrit:move";
-  public final static String TAG_RESTORE =
+  public static final String TAG_RESTORE =
       "autogenerated:gerrit:restore";
-  public final static String TAG_REVERT =
+  public static final String TAG_REVERT =
       "autogenerated:gerrit:revert";
-  public final static String TAG_SET_ASSIGNEE =
+  public static final String TAG_SET_ASSIGNEE =
       "autogenerated:gerrit:setAssignee";
-  public final static String TAG_SET_HASHTAGS =
+  public static final String TAG_SET_DESCRIPTION =
+      "autogenerated:gerrit:setPsDescription";
+  public static final String TAG_SET_HASHTAGS =
       "autogenerated:gerrit:setHashtag";
-  public final static String TAG_SET_TOPIC =
+  public static final String TAG_SET_TOPIC =
       "autogenerated:gerrit:setTopic";
-  public final static String TAG_UPLOADED_PATCH_SET =
+  public static final String TAG_UPLOADED_PATCH_SET =
       "autogenerated:gerrit:newPatchSet";
 
   public static ChangeMessage newMessage( BatchUpdate.ChangeContext ctx,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java
index e65879b..d664de9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java
@@ -100,6 +100,8 @@
               .compare(a.patchSet, b.patchSet, NULLS_FIRST)
               .compare(side(a), side(b))
               .compare(a.line, b.line, NULLS_FIRST)
+              .compare(a.inReplyTo, b.inReplyTo, NULLS_FIRST)
+              .compare(a.message, b.message)
               .compare(a.id, b.id)
               .result();
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/LibModuleLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/LibModuleLoader.java
new file mode 100644
index 0000000..abea78f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/LibModuleLoader.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Injector;
+import com.google.inject.Key;
+import com.google.inject.Module;
+import com.google.inject.ProvisionException;
+
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Arrays;
+import java.util.List;
+
+/** Loads configured Guice modules from {@code gerrit.installModule}. */
+public class LibModuleLoader {
+  private static final Logger log =
+      LoggerFactory.getLogger(LibModuleLoader.class);
+
+  public static List<Module> loadModules(Injector parent) {
+    Config cfg = getConfig(parent);
+    return Arrays.stream(cfg.getStringList("gerrit", null, "installModule"))
+        .map(m -> createModule(parent, m))
+        .collect(toList());
+  }
+
+  private static Config getConfig(Injector i) {
+    return i.getInstance(Key.get(Config.class, GerritServerConfig.class));
+  }
+
+  private static Module createModule(Injector injector, String className) {
+    Module m = injector.getInstance(loadModule(className));
+    log.info("Installed module {}", className);
+    return m;
+  }
+
+  @SuppressWarnings("unchecked")
+  private static Class<Module> loadModule(String className) {
+    try {
+      return (Class<Module>) Class.forName(className);
+    } catch (ClassNotFoundException | LinkageError e) {
+      String msg = "Cannot load LibModule " + className;
+      log.error(msg, e);
+      throw new ProvisionException(msg, e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerRecommender.java b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerRecommender.java
index 0ee10f0..91b568c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerRecommender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerRecommender.java
@@ -188,10 +188,10 @@
 
   private Map<Account.Id, MutableDouble> baseRankingForEmptyQuery(
       double baseWeight) throws OrmException{
-    // Get the user's last 50 changes, check approvals
+    // Get the user's last 25 changes, check approvals
     try {
       List<ChangeData> result = internalChangeQuery
-          .setLimit(50)
+          .setLimit(25)
           .setRequestedFields(ImmutableSet.of(ChangeField.REVIEWER.getName()))
           .query(changeQueryBuilder.owner("self"));
       Map<Account.Id, MutableDouble> suggestions = new HashMap<>();
@@ -260,7 +260,7 @@
     }
 
     List<List<ChangeData>> result = internalChangeQuery
-        .setLimit(100 * predicates.size())
+        .setLimit(25)
         .setRequestedFields(ImmutableSet.of())
         .query(predicates);
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewersUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewersUtil.java
index a09537a..d045bfe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewersUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewersUtil.java
@@ -174,11 +174,12 @@
     List<SuggestedReviewerInfo> suggestedReviewer =
         loadAccounts(sortedRecommendations);
 
-    if (!excludeGroups && !Strings.isNullOrEmpty(query)) {
+    if (!excludeGroups && suggestedReviewer.size() < limit
+        && !Strings.isNullOrEmpty(query)) {
       // Add groups at the end as individual accounts are usually more
       // important.
-      suggestedReviewer.addAll(suggestAccountGroups(
-          suggestReviewers, projectControl, visibilityControl));
+      suggestedReviewer.addAll(suggestAccountGroups(suggestReviewers,
+          projectControl, visibilityControl, limit - suggestedReviewer.size()));
     }
 
     if (suggestedReviewer.size() <= limit) {
@@ -299,7 +300,8 @@
 
   private List<SuggestedReviewerInfo> suggestAccountGroups(
       SuggestReviewers suggestReviewers, ProjectControl projectControl,
-      VisibilityControl visibilityControl) throws OrmException, IOException {
+      VisibilityControl visibilityControl, int limit)
+          throws OrmException, IOException {
     try (Timer0.Context ctx = metrics.queryGroupsLatency.start()) {
       List<SuggestedReviewerInfo> groups = new ArrayList<>();
       for (GroupReference g : suggestAccountGroups(suggestReviewers,
@@ -318,6 +320,9 @@
             suggestedReviewerInfo.confirm = true;
           }
           groups.add(suggestedReviewerInfo);
+          if (groups.size() >= limit) {
+            break;
+          }
         }
       }
       return groups;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
index 8f25e43..cc6f5db 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -14,18 +14,21 @@
 
 package com.google.gerrit.server;
 
+import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedSet;
-import com.google.common.collect.Iterables;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
@@ -61,6 +64,8 @@
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
+import java.util.Collection;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
 import java.util.SortedSet;
@@ -99,6 +104,25 @@
     }
   }
 
+  @AutoValue
+  public abstract static class StarRef {
+    private static final StarRef MISSING =
+        new AutoValue_StarredChangesUtil_StarRef(null, ImmutableSortedSet.of());
+
+    private static StarRef create(Ref ref, Iterable<String> labels) {
+      return new AutoValue_StarredChangesUtil_StarRef(
+          checkNotNull(ref),
+          ImmutableSortedSet.copyOf(labels));
+    }
+
+    @Nullable public abstract Ref ref();
+    public abstract ImmutableSortedSet<String> labels();
+
+    public ObjectId objectId() {
+      return ref() != null ? ref().getObjectId() : ObjectId.zeroId();
+    }
+  }
+
   public static class IllegalLabelException extends IllegalArgumentException {
     private static final long serialVersionUID = 1L;
 
@@ -153,8 +177,8 @@
   public ImmutableSortedSet<String> getLabels(Account.Id accountId,
       Change.Id changeId) throws OrmException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      return ImmutableSortedSet.copyOf(
-          readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)));
+      return readLabels(repo, RefNames.refsStarredChanges(changeId, accountId))
+          .labels();
     } catch (IOException e) {
       throw new OrmException(
           String.format("Reading stars from change %d for account %d failed",
@@ -167,9 +191,9 @@
       Set<String> labelsToRemove) throws OrmException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       String refName = RefNames.refsStarredChanges(changeId, accountId);
-      ObjectId oldObjectId = getObjectId(repo, refName);
+      StarRef old = readLabels(repo, refName);
 
-      SortedSet<String> labels = readLabels(repo, oldObjectId);
+      Set<String> labels = new HashSet<>(old.labels());
       if (labelsToAdd != null) {
         labels.addAll(labelsToAdd);
       }
@@ -178,10 +202,10 @@
       }
 
       if (labels.isEmpty()) {
-        deleteRef(repo, refName, oldObjectId);
+        deleteRef(repo, refName, old.objectId());
       } else {
         checkMutuallyExclusiveLabels(labels);
-        updateLabels(repo, refName, oldObjectId, labels);
+        updateLabels(repo, refName, old.objectId(), labels);
       }
 
       indexer.index(dbProvider.get(), project, changeId);
@@ -222,11 +246,11 @@
     }
   }
 
-  public ImmutableMultimap<Account.Id, String> byChange(Change.Id changeId)
+  public ImmutableMap<Account.Id, StarRef> byChange(Change.Id changeId)
       throws OrmException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      ImmutableMultimap.Builder<Account.Id, String> builder =
-          new ImmutableMultimap.Builder<>();
+      ImmutableMap.Builder<Account.Id, StarRef> builder =
+          ImmutableMap.builder();
       for (String refPart : getRefNames(repo,
           RefNames.refsStarredChangesPrefix(changeId))) {
         Integer id = Ints.tryParse(refPart);
@@ -234,7 +258,7 @@
           continue;
         }
         Account.Id accountId = new Account.Id(id);
-        builder.putAll(accountId,
+        builder.put(accountId,
             readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)));
       }
       return builder.build();
@@ -280,7 +304,7 @@
       Account.Id accountId, String label) {
     try {
       return readLabels(repo,
-          RefNames.refsStarredChanges(changeId, accountId))
+          RefNames.refsStarredChanges(changeId, accountId)).labels()
               .contains(label);
     } catch (IOException e) {
       log.error(String.format(
@@ -311,8 +335,8 @@
 
   public ObjectId getObjectId(Account.Id accountId, Change.Id changeId) {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      return getObjectId(repo,
-          RefNames.refsStarredChanges(changeId, accountId));
+      Ref ref = repo.exactRef(RefNames.refsStarredChanges(changeId, accountId));
+      return ref != null ? ref.getObjectId() : ObjectId.zeroId();
     } catch (IOException e) {
       log.error(String.format(
           "Getting star object ID for account %d on change %d failed",
@@ -321,39 +345,33 @@
     }
   }
 
-  private static ObjectId getObjectId(Repository repo, String refName)
+  private static StarRef readLabels(Repository repo, String refName)
       throws IOException {
     Ref ref = repo.exactRef(refName);
-    return ref != null ? ref.getObjectId() : ObjectId.zeroId();
-  }
-
-  private static SortedSet<String> readLabels(Repository repo, String refName)
-      throws IOException {
-    return readLabels(repo, getObjectId(repo, refName));
-  }
-
-  private static TreeSet<String> readLabels(Repository repo, ObjectId id)
-      throws IOException {
-    if (ObjectId.zeroId().equals(id)) {
-      return new TreeSet<>();
+    if (ref == null) {
+      return StarRef.MISSING;
     }
 
     try (ObjectReader reader = repo.newObjectReader()) {
-      ObjectLoader obj = reader.open(id, Constants.OBJ_BLOB);
-      TreeSet<String> labels = new TreeSet<>();
-      Iterables.addAll(labels,
+      ObjectLoader obj = reader.open(ref.getObjectId(), Constants.OBJ_BLOB);
+      return StarRef.create(
+          ref,
           Splitter.on(CharMatcher.whitespace()).omitEmptyStrings()
               .split(new String(obj.getCachedBytes(Integer.MAX_VALUE), UTF_8)));
-      return labels;
     }
   }
 
-  public static ObjectId writeLabels(Repository repo, SortedSet<String> labels)
+  public static ObjectId writeLabels(Repository repo, Collection<String> labels)
       throws IOException {
     validateLabels(labels);
     try (ObjectInserter oi = repo.newObjectInserter()) {
-      ObjectId id = oi.insert(Constants.OBJ_BLOB,
-          Joiner.on("\n").join(labels).getBytes(UTF_8));
+      ObjectId id = oi.insert(
+          Constants.OBJ_BLOB,
+          labels.stream()
+              .sorted()
+              .distinct()
+              .collect(joining("\n"))
+              .getBytes(UTF_8));
       oi.flush();
       return id;
     }
@@ -366,7 +384,7 @@
     }
   }
 
-  private static void validateLabels(Set<String> labels) {
+  private static void validateLabels(Collection<String> labels) {
     if (labels == null) {
       return;
     }
@@ -383,7 +401,7 @@
   }
 
   private void updateLabels(Repository repo, String refName,
-      ObjectId oldObjectId, SortedSet<String> labels)
+      ObjectId oldObjectId, Collection<String> labels)
           throws IOException, OrmException {
     try (RevWalk rw = new RevWalk(repo)) {
       RefUpdate u = repo.updateRef(refName);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/WebLinks.java b/gerrit-server/src/main/java/com/google/gerrit/server/WebLinks.java
index 6dccbc2..7b9733e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/WebLinks.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/WebLinks.java
@@ -36,6 +36,8 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.util.List;
+
 @Singleton
 public class WebLinks {
   private static final Logger log = LoggerFactory.getLogger(WebLinks.class);
@@ -94,7 +96,7 @@
    * @param commit SHA1 of commit.
    * @return Links for patch sets.
    */
-  public FluentIterable<WebLinkInfo> getPatchSetLinks(Project.NameKey project,
+  public List<WebLinkInfo> getPatchSetLinks(Project.NameKey project,
       String commit) {
     return filterLinks(
         patchSetLinks,
@@ -108,7 +110,7 @@
    * @param file File name.
    * @return Links for files.
    */
-  public FluentIterable<WebLinkInfo> getFileLinks(String project,
+  public List<WebLinkInfo> getFileLinks(String project,
       String revision, String file) {
     return filterLinks(
         fileLinks,
@@ -122,14 +124,7 @@
    * @param file File name.
    * @return Links for file history
    */
-  public FluentIterable<WebLinkInfo> getFileHistoryLinks(String project,
-      String revision, String file) {
-    return filterLinks(
-        fileHistoryLinks,
-        webLink -> webLink.getFileHistoryWebLink(project, revision, file));
-  }
-
-  public FluentIterable<WebLinkInfoCommon> getFileHistoryLinksCommon(
+  public List<WebLinkInfoCommon> getFileHistoryLinks(
       String project, String revision, String file) {
     return FluentIterable
         .from(fileHistoryLinks)
@@ -147,7 +142,8 @@
               commonInfo.target = info.target;
               return commonInfo;
             })
-        .filter(INVALID_WEBLINK_COMMON);
+        .filter(INVALID_WEBLINK_COMMON)
+        .toList();
   }
 
   /**
@@ -162,7 +158,7 @@
    * @param fileB File name of side B.
    * @return Links for file diffs.
    */
-  public FluentIterable<DiffWebLinkInfo> getDiffLinks(final String project, final int changeId,
+  public List<DiffWebLinkInfo> getDiffLinks(final String project, final int changeId,
       final Integer patchSetIdA, final String revisionA, final String fileA,
       final int patchSetIdB, final String revisionB, final String fileB) {
    return FluentIterable
@@ -171,7 +167,8 @@
             webLink.getDiffLink(project, changeId,
                 patchSetIdA, revisionA, fileA,
                 patchSetIdB, revisionB, fileB))
-       .filter(INVALID_WEBLINK);
+       .filter(INVALID_WEBLINK)
+       .toList();
  }
 
   /**
@@ -179,7 +176,7 @@
    * @param project Project name.
    * @return Links for projects.
    */
-  public FluentIterable<WebLinkInfo> getProjectLinks(final String project) {
+  public List<WebLinkInfo> getProjectLinks(final String project) {
     return filterLinks(
         projectLinks,
         webLink -> webLink.getProjectWeblink(project));
@@ -191,17 +188,18 @@
    * @param branch Branch name
    * @return Links for branches.
    */
-  public FluentIterable<WebLinkInfo> getBranchLinks(final String project, final String branch) {
+  public List<WebLinkInfo> getBranchLinks(final String project, final String branch) {
     return filterLinks(
         branchLinks,
         webLink -> webLink.getBranchWebLink(project, branch));
   }
 
-  private <T extends WebLink> FluentIterable<WebLinkInfo> filterLinks(DynamicSet<T> links,
+  private <T extends WebLink> List<WebLinkInfo> filterLinks(DynamicSet<T> links,
       Function<T, WebLinkInfo> transformer) {
     return FluentIterable
         .from(links)
         .transform(transformer)
-        .filter(INVALID_WEBLINK);
+        .filter(INVALID_WEBLINK)
+        .toList();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
index 8baef83..d0c6a84 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
@@ -396,8 +396,8 @@
 
         if (who.getEmailAddress() != null) {
           byEmailCache.evict(who.getEmailAddress());
-          byIdCache.evict(to);
         }
+        byIdCache.evict(to);
       }
 
       return new AuthResult(to, key, false);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GeneralPreferencesLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GeneralPreferencesLoader.java
index 8339baf..24a0dae 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GeneralPreferencesLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GeneralPreferencesLoader.java
@@ -16,6 +16,8 @@
 
 import static com.google.gerrit.server.config.ConfigUtil.loadSection;
 import static com.google.gerrit.server.config.ConfigUtil.skipField;
+import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE;
+import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE_COLUMN;
 import static com.google.gerrit.server.git.UserConfigSections.KEY_ID;
 import static com.google.gerrit.server.git.UserConfigSections.KEY_MATCH;
 import static com.google.gerrit.server.git.UserConfigSections.KEY_TARGET;
@@ -24,6 +26,7 @@
 import static com.google.gerrit.server.git.UserConfigSections.URL_ALIAS;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.MenuItem;
 import com.google.gerrit.reviewdb.client.Account;
@@ -91,7 +94,7 @@
           loadSection(p.getConfig(), UserConfigSections.GENERAL, null,
           new GeneralPreferencesInfo(),
           updateDefaults(allUserPrefs), in);
-
+      loadChangeTableColumns(r, p, dp);
       return loadMyMenusAndUrlAliases(r, p, dp);
     }
   }
@@ -161,6 +164,21 @@
     return !Strings.isNullOrEmpty(val) ? val : defaultValue;
   }
 
+  public GeneralPreferencesInfo loadChangeTableColumns(GeneralPreferencesInfo r,
+      VersionedAccountPreferences v, VersionedAccountPreferences d) {
+    r.changeTable = changeTable(v);
+
+    if (r.changeTable.isEmpty() && !v.isDefaults()) {
+      r.changeTable = changeTable(d);
+    }
+    return r;
+  }
+
+  private static List<String> changeTable(VersionedAccountPreferences v) {
+    return Lists.newArrayList(v.getConfig().getStringList(
+        CHANGE_TABLE, null, CHANGE_TABLE_COLUMN));
+  }
+
   private static Map<String, String> urlAliases(VersionedAccountPreferences v) {
     HashMap<String, String> urlAliases = new HashMap<>();
     Config cfg = v.getConfig();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCache.java
index 9971301..0d1fd20 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCache.java
@@ -16,18 +16,18 @@
 
 import com.google.gerrit.reviewdb.client.AccountGroup;
 
-import java.util.Set;
+import java.util.Collection;
 
 /** Tracks group inclusions in memory for efficient access. */
 public interface GroupIncludeCache {
   /** @return groups directly a member of the passed group. */
-  Set<AccountGroup.UUID> subgroupsOf(AccountGroup.UUID group);
+  Collection<AccountGroup.UUID> subgroupsOf(AccountGroup.UUID group);
 
   /** @return any groups the passed group belongs to. */
-  Set<AccountGroup.UUID> parentGroupsOf(AccountGroup.UUID groupId);
+  Collection<AccountGroup.UUID> parentGroupsOf(AccountGroup.UUID groupId);
 
   /** @return set of any UUIDs that are not internal groups. */
-  Set<AccountGroup.UUID> allExternalMembers();
+  Collection<AccountGroup.UUID> allExternalMembers();
 
   void evictSubgroupsOf(AccountGroup.UUID groupId);
   void evictParentGroupsOf(AccountGroup.UUID groupId);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
index 9bd6b30..02889bf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
@@ -16,11 +16,12 @@
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
-import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountGroupById;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Module;
@@ -31,6 +32,7 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
@@ -52,17 +54,17 @@
       protected void configure() {
         cache(PARENT_GROUPS_NAME,
             AccountGroup.UUID.class,
-            new TypeLiteral<Set<AccountGroup.UUID>>() {})
+            new TypeLiteral<ImmutableList<AccountGroup.UUID>>() {})
           .loader(ParentGroupsLoader.class);
 
         cache(SUBGROUPS_NAME,
             AccountGroup.UUID.class,
-            new TypeLiteral<Set<AccountGroup.UUID>>() {})
+            new TypeLiteral<ImmutableList<AccountGroup.UUID>>() {})
           .loader(SubgroupsLoader.class);
 
         cache(EXTERNAL_NAME,
             String.class,
-            new TypeLiteral<Set<AccountGroup.UUID>>() {})
+            new TypeLiteral<ImmutableList<AccountGroup.UUID>>() {})
           .loader(AllExternalLoader.class);
 
         bind(GroupIncludeCacheImpl.class);
@@ -71,22 +73,31 @@
     };
   }
 
-  private final LoadingCache<AccountGroup.UUID, Set<AccountGroup.UUID>> subgroups;
-  private final LoadingCache<AccountGroup.UUID, Set<AccountGroup.UUID>> parentGroups;
-  private final LoadingCache<String, Set<AccountGroup.UUID>> external;
+  private final
+      LoadingCache<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>>
+          subgroups;
+  private final
+      LoadingCache<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>>
+          parentGroups;
+  private final LoadingCache<String, ImmutableList<AccountGroup.UUID>> external;
 
   @Inject
   GroupIncludeCacheImpl(
-      @Named(SUBGROUPS_NAME) LoadingCache<AccountGroup.UUID, Set<AccountGroup.UUID>> subgroups,
-      @Named(PARENT_GROUPS_NAME) LoadingCache<AccountGroup.UUID, Set<AccountGroup.UUID>> parentGroups,
-      @Named(EXTERNAL_NAME) LoadingCache<String, Set<AccountGroup.UUID>> external) {
+      @Named(SUBGROUPS_NAME)
+      LoadingCache<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>>
+          subgroups,
+      @Named(PARENT_GROUPS_NAME)
+      LoadingCache<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>>
+          parentGroups,
+      @Named(EXTERNAL_NAME)
+      LoadingCache<String, ImmutableList<AccountGroup.UUID>> external) {
     this.subgroups = subgroups;
     this.parentGroups = parentGroups;
     this.external = external;
   }
 
   @Override
-  public Set<AccountGroup.UUID> subgroupsOf(AccountGroup.UUID groupId) {
+  public Collection<AccountGroup.UUID> subgroupsOf(AccountGroup.UUID groupId) {
     try {
       return subgroups.get(groupId);
     } catch (ExecutionException e) {
@@ -96,7 +107,8 @@
   }
 
   @Override
-  public Set<AccountGroup.UUID> parentGroupsOf(AccountGroup.UUID groupId) {
+  public Collection<AccountGroup.UUID> parentGroupsOf(
+      AccountGroup.UUID groupId) {
     try {
       return parentGroups.get(groupId);
     } catch (ExecutionException e) {
@@ -124,17 +136,17 @@
   }
 
   @Override
-  public Set<AccountGroup.UUID> allExternalMembers() {
+  public Collection<AccountGroup.UUID> allExternalMembers() {
     try {
       return external.get(EXTERNAL_NAME);
     } catch (ExecutionException e) {
       log.warn("Cannot load set of non-internal groups", e);
-      return Collections.emptySet();
+      return ImmutableList.of();
     }
   }
 
   static class SubgroupsLoader extends
-      CacheLoader<AccountGroup.UUID, Set<AccountGroup.UUID>> {
+      CacheLoader<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> {
     private final SchemaFactory<ReviewDb> schema;
 
     @Inject
@@ -143,11 +155,12 @@
     }
 
     @Override
-    public Set<AccountGroup.UUID> load(AccountGroup.UUID key) throws Exception {
+    public ImmutableList<AccountGroup.UUID> load(
+        AccountGroup.UUID key) throws OrmException {
       try (ReviewDb db = schema.open()) {
         List<AccountGroup> group = db.accountGroups().byUUID(key).toList();
         if (group.size() != 1) {
-          return Collections.emptySet();
+          return ImmutableList.of();
         }
 
         Set<AccountGroup.UUID> ids = new HashSet<>();
@@ -155,13 +168,13 @@
             .byGroup(group.get(0).getId())) {
           ids.add(agi.getIncludeUUID());
         }
-        return ImmutableSet.copyOf(ids);
+        return ImmutableList.copyOf(ids);
       }
     }
   }
 
   static class ParentGroupsLoader extends
-      CacheLoader<AccountGroup.UUID, Set<AccountGroup.UUID>> {
+      CacheLoader<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> {
     private final SchemaFactory<ReviewDb> schema;
 
     @Inject
@@ -170,7 +183,8 @@
     }
 
     @Override
-    public Set<AccountGroup.UUID> load(AccountGroup.UUID key) throws Exception {
+    public ImmutableList<AccountGroup.UUID> load(AccountGroup.UUID key)
+        throws OrmException {
       try (ReviewDb db = schema.open()) {
         Set<AccountGroup.Id> ids = new HashSet<>();
         for (AccountGroupById agi : db.accountGroupById()
@@ -182,13 +196,13 @@
         for (AccountGroup g : db.accountGroups().get(ids)) {
           groupArray.add(g.getGroupUUID());
         }
-        return ImmutableSet.copyOf(groupArray);
+        return ImmutableList.copyOf(groupArray);
       }
     }
   }
 
   static class AllExternalLoader extends
-      CacheLoader<String, Set<AccountGroup.UUID>> {
+      CacheLoader<String, ImmutableList<AccountGroup.UUID>> {
     private final SchemaFactory<ReviewDb> schema;
 
     @Inject
@@ -197,7 +211,7 @@
     }
 
     @Override
-    public Set<AccountGroup.UUID> load(String key) throws Exception {
+    public ImmutableList<AccountGroup.UUID> load(String key) throws Exception {
       try (ReviewDb db = schema.open()) {
         Set<AccountGroup.UUID> ids = new HashSet<>();
         for (AccountGroupById agi : db.accountGroupById().all()) {
@@ -205,7 +219,7 @@
             ids.add(agi.getIncludeUUID());
           }
         }
-        return ImmutableSet.copyOf(ids);
+        return ImmutableList.copyOf(ids);
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/IncludingGroupMembership.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/IncludingGroupMembership.java
index 3eaeebe..f38d071 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/IncludingGroupMembership.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/IncludingGroupMembership.java
@@ -112,7 +112,7 @@
     return r;
   }
 
-  private boolean search(Set<AccountGroup.UUID> ids) {
+  private boolean search(Iterable<AccountGroup.UUID> ids) {
     return user.getEffectiveGroups().containsAnyOf(ids);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java
new file mode 100644
index 0000000..b8cdf76
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java
@@ -0,0 +1,56 @@
+//Copyright (C) 2016 The Android Open Source Project
+//
+//Licensed under the Apache License, Version 2.0 (the "License");
+//you may not use this file except in compliance with the License.
+//You may obtain a copy of the License at
+//
+//http://www.apache.org/licenses/LICENSE-2.0
+//
+//Unless required by applicable law or agreed to in writing, software
+//distributed under the License is distributed on an "AS IS" BASIS,
+//WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//See the License for the specific language governing permissions and
+//limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.Index.Input;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import java.io.IOException;
+
+@Singleton
+public class Index implements RestModifyView<AccountResource, Input> {
+  public static class Input {
+  }
+
+  private final AccountCache accountCache;
+  private final Provider<CurrentUser> self;
+
+  @Inject
+  Index(AccountCache accountCache,
+      Provider<CurrentUser> self) {
+    this.accountCache = accountCache;
+    this.self = self;
+  }
+
+  @Override
+  public Response<?> apply(AccountResource rsrc, Input input)
+      throws IOException, AuthException, OrmException {
+    if (self.get() != rsrc.getUser()
+        && !self.get().getCapabilities().canModifyAccount()) {
+      throw new AuthException("not allowed to index account");
+    }
+
+    // evicting the account from the cache, reindexes the account
+    accountCache.evict(rsrc.getUser().getAccountId());
+    return Response.none();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java
index 5b4a200..8c5228f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java
@@ -40,6 +40,7 @@
     put(ACCOUNT_KIND).to(PutAccount.class);
     get(ACCOUNT_KIND).to(GetAccount.class);
     get(ACCOUNT_KIND, "detail").to(GetDetail.class);
+    post(ACCOUNT_KIND, "index").to(Index.class);
     get(ACCOUNT_KIND, "name").to(GetName.class);
     put(ACCOUNT_KIND, "name").to(PutName.class);
     delete(ACCOUNT_KIND, "name").to(PutName.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java
index b70cabd..3714cee 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account;
 
 import static com.google.gerrit.server.config.ConfigUtil.storeSection;
+import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE_COLUMN;
 import static com.google.gerrit.server.git.UserConfigSections.KEY_ID;
 import static com.google.gerrit.server.git.UserConfigSections.KEY_MATCH;
 import static com.google.gerrit.server.git.UserConfigSections.KEY_TARGET;
@@ -87,6 +88,7 @@
     Account.Id id = rsrc.getUser().getAccountId();
     GeneralPreferencesInfo n = loader.merge(id, i);
 
+    n.changeTable = i.changeTable;
     n.my = i.my;
     n.urlAliases = i.urlAliases;
 
@@ -105,6 +107,7 @@
       storeSection(prefs.getConfig(), UserConfigSections.GENERAL, null, i,
           GeneralPreferencesInfo.defaults());
 
+      storeMyChangeTableColumns(prefs, i.changeTable);
       storeMyMenus(prefs, i.my);
       storeUrlAliases(prefs, i.urlAliases);
       prefs.commit(md);
@@ -125,6 +128,16 @@
     }
   }
 
+  public static void storeMyChangeTableColumns(VersionedAccountPreferences
+      prefs, List<String> changeTable) {
+    Config cfg = prefs.getConfig();
+    if (changeTable != null) {
+      unsetSection(cfg, UserConfigSections.CHANGE_TABLE);
+      cfg.setStringList(UserConfigSections.CHANGE_TABLE, null,
+          CHANGE_TABLE_COLUMN, changeTable);
+    }
+  }
+
   private static void set(Config cfg, String section, String key, String val) {
     if (Strings.isNullOrEmpty(val)) {
       cfg.unset(UserConfigSections.MY, section, key);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index 3533fe8..5e010ae 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -52,6 +52,7 @@
 import com.google.gerrit.server.account.GetPreferences;
 import com.google.gerrit.server.account.GetSshKeys;
 import com.google.gerrit.server.account.GetWatchedProjects;
+import com.google.gerrit.server.account.Index;
 import com.google.gerrit.server.account.PostWatchedProjects;
 import com.google.gerrit.server.account.PutActive;
 import com.google.gerrit.server.account.PutAgreement;
@@ -108,6 +109,7 @@
   private final GetActive getActive;
   private final PutActive putActive;
   private final DeleteActive deleteActive;
+  private final Index index;
 
   @Inject
   AccountApiImpl(AccountLoader.Factory ailf,
@@ -138,6 +140,7 @@
       GetActive getActive,
       PutActive putActive,
       DeleteActive deleteActive,
+      Index index,
       @Assisted AccountResource account) {
     this.account = account;
     this.accountLoaderFactory = ailf;
@@ -168,6 +171,7 @@
     this.getActive = getActive;
     this.putActive = putActive;
     this.deleteActive = deleteActive;
+    this.index = index;
   }
 
   @Override
@@ -435,4 +439,12 @@
     }
   }
 
+  @Override
+  public void index() throws RestApiException {
+    try {
+      index.apply(account, new Index.Input());
+    } catch (IOException | OrmException e) {
+      throw new RestApiException("Cannot index account", e);
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index abbcef6..6ba11d5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -47,6 +47,7 @@
 import com.google.gerrit.server.change.DraftComments;
 import com.google.gerrit.server.change.FileResource;
 import com.google.gerrit.server.change.Files;
+import com.google.gerrit.server.change.GetDescription;
 import com.google.gerrit.server.change.GetMergeList;
 import com.google.gerrit.server.change.GetPatch;
 import com.google.gerrit.server.change.GetRevisionActions;
@@ -57,6 +58,7 @@
 import com.google.gerrit.server.change.PostReview;
 import com.google.gerrit.server.change.PreviewSubmit;
 import com.google.gerrit.server.change.PublishDraftPatchSet;
+import com.google.gerrit.server.change.PutDescription;
 import com.google.gerrit.server.change.Rebase;
 import com.google.gerrit.server.change.RebaseUtil;
 import com.google.gerrit.server.change.Reviewed;
@@ -118,6 +120,8 @@
   private final TestSubmitType testSubmitType;
   private final TestSubmitType.Get getSubmitType;
   private final Provider<GetMergeList> getMergeList;
+  private final PutDescription putDescription;
+  private final GetDescription getDescription;
 
   @Inject
   RevisionApiImpl(GitRepositoryManager repoManager,
@@ -151,6 +155,8 @@
       TestSubmitType testSubmitType,
       TestSubmitType.Get getSubmitType,
       Provider<GetMergeList> getMergeList,
+      PutDescription putDescription,
+      GetDescription getDescription,
       @Assisted RevisionResource r) {
     this.repoManager = repoManager;
     this.changes = changes;
@@ -183,6 +189,8 @@
     this.testSubmitType = testSubmitType;
     this.getSubmitType = getSubmitType;
     this.getMergeList = getMergeList;
+    this.putDescription = putDescription;
+    this.getDescription = getDescription;
     this.revision = r;
   }
 
@@ -473,7 +481,11 @@
 
   @Override
   public Map<String, ActionInfo> actions() throws RestApiException {
-    return revisionActions.apply(revision).value();
+    try {
+      return revisionActions.apply(revision).value();
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot get actions", e);
+    }
   }
 
   @Override
@@ -511,4 +523,25 @@
       }
     };
   }
+
+  @Override
+  public void description(String description) throws RestApiException {
+    PutDescription.Input in = new PutDescription.Input();
+    in.description = description;
+    try {
+      putDescription.apply(revision, in);
+    } catch (UpdateException e) {
+      throw new RestApiException("Cannot set description", e);
+    }
+  }
+
+  @Override
+  public String description() throws RestApiException {
+    return getDescription.apply(revision);
+  }
+
+  @Override
+  public String etag() throws RestApiException {
+    return revisionActions.getETag(revision);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
index 4148e7a..4af066f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
@@ -19,6 +19,7 @@
 import com.google.common.base.Strings;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
+import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.ParameterizedString;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.client.AuthType;
@@ -30,6 +31,7 @@
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.EmailExpander;
+import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.auth.AuthenticationUnavailableException;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -70,6 +72,9 @@
   private final LoadingCache<String, Optional<Account.Id>> usernameCache;
   private final Set<AccountFieldName> readOnlyAccountFields;
   private final boolean fetchMemberOfEagerly;
+  private final String mandatoryGroup;
+  private final LdapGroupBackend groupBackend;
+
   private final Config config;
 
   private final LoadingCache<String, Set<AccountGroup.UUID>> membershipCache;
@@ -79,12 +84,14 @@
       Helper helper,
       AuthConfig authConfig,
       EmailExpander emailExpander,
+      LdapGroupBackend groupBackend,
       @Named(LdapModule.GROUP_CACHE) final LoadingCache<String, Set<AccountGroup.UUID>> membershipCache,
       @Named(LdapModule.USERNAME_CACHE) final LoadingCache<String, Optional<Account.Id>> usernameCache,
       @GerritServerConfig final Config config) {
     this.helper = helper;
     this.authConfig = authConfig;
     this.emailExpander = emailExpander;
+    this.groupBackend = groupBackend;
     this.usernameCache = usernameCache;
     this.membershipCache = membershipCache;
     this.config = config;
@@ -102,6 +109,7 @@
     }
 
     fetchMemberOfEagerly = optional(config, "fetchMemberOfEagerly", true);
+    mandatoryGroup = optional(config, "mandatoryGroup");
   }
 
   static SearchScope scope(final Config c, final String setting) {
@@ -263,8 +271,23 @@
         // in the middle of authenticating the user, its likely we will
         // need to know what access rights they have soon.
         //
-        if (fetchMemberOfEagerly) {
-          membershipCache.put(username, helper.queryForGroups(ctx, username, m));
+        if (fetchMemberOfEagerly || mandatoryGroup != null) {
+          Set<AccountGroup.UUID> groups = helper.queryForGroups(ctx, username, m);
+          if (mandatoryGroup != null) {
+            GroupReference mandatoryGroupRef =
+                GroupBackends.findExactSuggestion(groupBackend, mandatoryGroup);
+            if (mandatoryGroupRef == null) {
+              throw new AccountException("Could not identify mandatory group: " +
+                  mandatoryGroup);
+            }
+            if (!groups.contains(mandatoryGroupRef.getUUID())) {
+              throw new AccountException("Not member of mandatory LDAP group: " +
+                  mandatoryGroupRef.getName());
+            }
+          }
+          // Regardless if we enabled fetchMemberOfEagerly, we already have the
+          // groups and it would be a waste not to cache them.
+          membershipCache.put(username, groups);
         }
         return who;
       } finally {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java
index 9c67fe0..69edc7e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.base.Strings;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -26,21 +25,12 @@
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.extensions.events.ChangeAbandoned;
+import com.google.gerrit.server.git.AbandonOp;
 import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.BatchUpdate.Context;
 import com.google.gerrit.server.git.UpdateException;
-import com.google.gerrit.server.mail.send.AbandonedSender;
-import com.google.gerrit.server.mail.send.ReplyToChangeSender;
-import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -57,29 +47,21 @@
     UiAction<ChangeResource> {
   private static final Logger log = LoggerFactory.getLogger(Abandon.class);
 
-  private final AbandonedSender.Factory abandonedSenderFactory;
   private final Provider<ReviewDb> dbProvider;
   private final ChangeJson.Factory json;
-  private final ChangeMessagesUtil cmUtil;
-  private final PatchSetUtil psUtil;
   private final BatchUpdate.Factory batchUpdateFactory;
-  private final ChangeAbandoned changeAbandoned;
+  private final AbandonOp.Factory abandonOpFactory;
 
   @Inject
-  Abandon(AbandonedSender.Factory abandonedSenderFactory,
+  Abandon(
       Provider<ReviewDb> dbProvider,
       ChangeJson.Factory json,
-      ChangeMessagesUtil cmUtil,
-      PatchSetUtil psUtil,
       BatchUpdate.Factory batchUpdateFactory,
-      ChangeAbandoned changeAbandoned) {
-    this.abandonedSenderFactory = abandonedSenderFactory;
+      AbandonOp.Factory abandonOpFactory) {
     this.dbProvider = dbProvider;
     this.json = json;
-    this.cmUtil = cmUtil;
-    this.psUtil = psUtil;
     this.batchUpdateFactory = batchUpdateFactory;
-    this.changeAbandoned = changeAbandoned;
+    this.abandonOpFactory = abandonOpFactory;
   }
 
   @Override
@@ -105,7 +87,11 @@
 
   public Change abandon(ChangeControl control, String msgTxt,
       NotifyHandling notifyHandling) throws RestApiException, UpdateException {
-    Op op = new Op(msgTxt, notifyHandling);
+    CurrentUser user = control.getUser();
+    Account account = user.isIdentifiedUser()
+        ? user.asIdentifiedUser().getAccount()
+        : null;
+    AbandonOp op = abandonOpFactory.create(account, msgTxt, notifyHandling);
     try (BatchUpdate u =
         batchUpdateFactory.create(
             dbProvider.get(),
@@ -114,7 +100,7 @@
             TimeUtil.nowTs())) {
       u.addOp(control.getId(), op).execute();
     }
-    return op.change;
+    return op.getChange();
   }
 
   /**
@@ -131,6 +117,9 @@
     if (controls.isEmpty()) {
       return;
     }
+    Account account = user.isIdentifiedUser()
+        ? user.asIdentifiedUser().getAccount()
+        : null;
     try (BatchUpdate u = batchUpdateFactory.create(
         dbProvider.get(), project, user, TimeUtil.nowTs())) {
       for (ChangeControl control : controls) {
@@ -141,7 +130,9 @@
                   control.getProject().getNameKey().get(),
                   project.get()));
         }
-        u.addOp(control.getId(), new Op(msgTxt, notifyHandling));
+        u.addOp(
+            control.getId(),
+            abandonOpFactory.create(account, msgTxt, notifyHandling));
       }
       u.execute();
     }
@@ -159,75 +150,6 @@
     batchAbandon(project, user, controls, "", NotifyHandling.ALL);
   }
 
-  private class Op extends BatchUpdate.Op {
-    private final String msgTxt;
-    private final NotifyHandling notifyHandling;
-
-    private Change change;
-    private PatchSet patchSet;
-    private ChangeMessage message;
-
-    private Op(String msgTxt, NotifyHandling notifyHandling) {
-      this.msgTxt = msgTxt;
-      this.notifyHandling = notifyHandling;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws OrmException,
-        ResourceConflictException {
-      change = ctx.getChange();
-      PatchSet.Id psId = change.currentPatchSetId();
-      ChangeUpdate update = ctx.getUpdate(psId);
-      if (!change.getStatus().isOpen()) {
-        throw new ResourceConflictException("change is " + status(change));
-      } else if (change.getStatus() == Change.Status.DRAFT) {
-        throw new ResourceConflictException(
-            "draft changes cannot be abandoned");
-      }
-      patchSet = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
-      change.setStatus(Change.Status.ABANDONED);
-      change.setLastUpdatedOn(ctx.getWhen());
-
-      update.setStatus(change.getStatus());
-      message = newMessage(ctx);
-      cmUtil.addChangeMessage(ctx.getDb(), update, message);
-      return true;
-    }
-
-    private ChangeMessage newMessage(ChangeContext ctx) throws OrmException {
-      StringBuilder msg = new StringBuilder();
-      msg.append("Abandoned");
-      if (!Strings.nullToEmpty(msgTxt).trim().isEmpty()) {
-        msg.append("\n\n");
-        msg.append(msgTxt.trim());
-      }
-
-      return ChangeMessagesUtil.newMessage(
-          ctx, msg.toString(), ChangeMessagesUtil.TAG_ABANDON);
-    }
-
-    @Override
-    public void postUpdate(Context ctx) throws OrmException {
-      Account account = ctx.getUser().isIdentifiedUser()
-          ? ctx.getUser().asIdentifiedUser().getAccount()
-          : null;
-      try {
-        ReplyToChangeSender cm =
-            abandonedSenderFactory.create(ctx.getProject(), change.getId());
-        if (account != null) {
-          cm.setFrom(account.getId());
-        }
-        cm.setChangeMessage(message.getMessage(), ctx.getWhen());
-        cm.setNotify(notifyHandling);
-        cm.send();
-      } catch (Exception e) {
-        log.error("Cannot email update for change " + change.getId(), e);
-      }
-      changeAbandoned.fire(change, patchSet, account, msgTxt, ctx.getWhen(),
-          notifyHandling);
-    }
-  }
-
   @Override
   public UiAction.Description getDescription(ChangeResource resource) {
     boolean canAbandon = false;
@@ -243,8 +165,4 @@
           && resource.getChange().getStatus() != Change.Status.DRAFT
           && canAbandon);
   }
-
-  private static String status(Change change) {
-    return change != null ? change.getStatus().name().toLowerCase() : "deleted";
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java
index 4992c8e..4c405d9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java
@@ -14,69 +14,164 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.ActionVisitor;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.webui.PrivateInternals_UiActionDescription;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.extensions.webui.UiActions;
 import com.google.gerrit.server.project.ChangeControl;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.util.Providers;
 
+import java.util.ArrayList;
+import java.util.EnumSet;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 
 @Singleton
 public class ActionJson {
   private final Revisions revisions;
+  private final ChangeJson.Factory changeJsonFactory;
   private final ChangeResource.Factory changeResourceFactory;
   private final DynamicMap<RestView<ChangeResource>> changeViews;
+  private final DynamicSet<ActionVisitor> visitorSet;
 
   @Inject
   ActionJson(
       Revisions revisions,
+      ChangeJson.Factory changeJsonFactory,
       ChangeResource.Factory changeResourceFactory,
-      DynamicMap<RestView<ChangeResource>> changeViews) {
+      DynamicMap<RestView<ChangeResource>> changeViews,
+      DynamicSet<ActionVisitor> visitorSet) {
     this.revisions = revisions;
+    this.changeJsonFactory = changeJsonFactory;
     this.changeResourceFactory = changeResourceFactory;
     this.changeViews = changeViews;
+    this.visitorSet = visitorSet;
   }
 
-  public Map<String, ActionInfo> format(RevisionResource rsrc) {
-    return toActionMap(rsrc);
+  public Map<String, ActionInfo> format(RevisionResource rsrc)
+      throws OrmException {
+    ChangeInfo changeInfo = null;
+    RevisionInfo revisionInfo = null;
+    List<ActionVisitor> visitors = visitors();
+    if (!visitors.isEmpty()) {
+      changeInfo = changeJson().format(rsrc);
+      revisionInfo =
+          checkNotNull(Iterables.getOnlyElement(changeInfo.revisions.values()));
+      changeInfo.revisions = null;
+    }
+    return toActionMap(rsrc, visitors, changeInfo, revisionInfo);
+  }
+
+  private ChangeJson changeJson() {
+    return changeJsonFactory.create(EnumSet.noneOf(ListChangesOption.class));
+  }
+
+  private ArrayList<ActionVisitor> visitors() {
+    return Lists.newArrayList(visitorSet);
   }
 
   public ChangeInfo addChangeActions(ChangeInfo to, ChangeControl ctl) {
-    to.actions = toActionMap(ctl);
+    List<ActionVisitor> visitors = visitors();
+    to.actions = toActionMap(ctl, visitors, copy(visitors, to));
     return to;
   }
 
-  public RevisionInfo addRevisionActions(RevisionInfo to,
-      RevisionResource rsrc) {
-    to.actions = toActionMap(rsrc);
+  public RevisionInfo addRevisionActions(@Nullable ChangeInfo changeInfo,
+      RevisionInfo to, RevisionResource rsrc) throws OrmException {
+    List<ActionVisitor> visitors = visitors();
+    if (!visitors.isEmpty()) {
+      if (changeInfo != null) {
+        changeInfo = copy(visitors, changeInfo);
+      } else {
+        changeInfo = changeJson().format(rsrc);
+      }
+    }
+    to.actions = toActionMap(rsrc, visitors, changeInfo, copy(visitors, to));
     return to;
   }
 
-  private Map<String, ActionInfo> toActionMap(ChangeControl ctl) {
+  private ChangeInfo copy(List<ActionVisitor> visitors, ChangeInfo changeInfo) {
+    if (visitors.isEmpty()) {
+      return null;
+    }
+    // Include all fields from ChangeJson#toChangeInfo that are not protected by
+    // any ListChangesOptions.
+    ChangeInfo copy = new ChangeInfo();
+    copy.project = changeInfo.project;
+    copy.branch = changeInfo.branch;
+    copy.topic = changeInfo.topic;
+    copy.assignee = changeInfo.assignee;
+    copy.hashtags = changeInfo.hashtags;
+    copy.changeId = changeInfo.changeId;
+    copy.submitType = changeInfo.submitType;
+    copy.mergeable = changeInfo.mergeable;
+    copy.insertions = changeInfo.insertions;
+    copy.deletions = changeInfo.deletions;
+    copy.subject = changeInfo.subject;
+    copy.status = changeInfo.status;
+    copy.owner = changeInfo.owner;
+    copy.created = changeInfo.created;
+    copy.updated = changeInfo.updated;
+    copy._number = changeInfo._number;
+    copy.starred = changeInfo.starred;
+    copy.stars = changeInfo.stars;
+    copy.submitted = changeInfo.submitted;
+    copy.id = changeInfo.id;
+    return copy;
+  }
+
+  private RevisionInfo copy(List<ActionVisitor> visitors,
+      RevisionInfo revisionInfo) {
+    if (visitors.isEmpty()) {
+      return null;
+    }
+    // Include all fields from ChangeJson#toRevisionInfo that are not protected
+    // by any ListChangesOptions.
+    RevisionInfo copy = new RevisionInfo();
+    copy.isCurrent = revisionInfo.isCurrent;
+    copy._number = revisionInfo._number;
+    copy.ref = revisionInfo.ref;
+    copy.created = revisionInfo.created;
+    copy.uploader = revisionInfo.uploader;
+    copy.draft = revisionInfo.draft;
+    copy.fetch = revisionInfo.fetch;
+    copy.kind = revisionInfo.kind;
+    copy.description = revisionInfo.description;
+    return copy;
+  }
+
+  private Map<String, ActionInfo> toActionMap(
+      ChangeControl ctl, List<ActionVisitor> visitors, ChangeInfo changeInfo) {
     Map<String, ActionInfo> out = new LinkedHashMap<>();
     if (!ctl.getUser().isIdentifiedUser()) {
       return out;
     }
 
     Provider<CurrentUser> userProvider = Providers.of(ctl.getUser());
-    for (UiAction.Description d : UiActions.from(
+    FluentIterable<UiAction.Description> descs = UiActions.from(
         changeViews,
         changeResourceFactory.create(ctl),
-        userProvider)) {
-      out.put(d.getId(), new ActionInfo(d));
-    }
-
+        userProvider);
     // The followup action is a client-side only operation that does not
     // have a server side handler. It must be manually registered into the
     // resulting action map.
@@ -86,20 +181,39 @@
       PrivateInternals_UiActionDescription.setMethod(descr, "POST");
       descr.setTitle("Create follow-up change");
       descr.setLabel("Follow-Up");
-      out.put(descr.getId(), new ActionInfo(descr));
+      descs = descs.append(descr);
+    }
+
+    ACTION: for (UiAction.Description d : descs) {
+      ActionInfo actionInfo = new ActionInfo(d);
+      for (ActionVisitor visitor : visitors) {
+        if (!visitor.visit(d.getId(), actionInfo, changeInfo)) {
+          continue ACTION;
+        }
+      }
+      out.put(d.getId(), actionInfo);
     }
     return out;
   }
 
-  private Map<String, ActionInfo> toActionMap(RevisionResource rsrc) {
+  private Map<String, ActionInfo> toActionMap(RevisionResource rsrc,
+      List<ActionVisitor> visitors, ChangeInfo changeInfo,
+      RevisionInfo revisionInfo) {
+    if (!rsrc.getControl().getUser().isIdentifiedUser()) {
+      return ImmutableMap.of();
+    }
     Map<String, ActionInfo> out = new LinkedHashMap<>();
-    if (rsrc.getControl().getUser().isIdentifiedUser()) {
-      Provider<CurrentUser> userProvider = Providers.of(
-          rsrc.getControl().getUser());
-      for (UiAction.Description d : UiActions.from(
-          revisions, rsrc, userProvider)) {
-        out.put(d.getId(), new ActionInfo(d));
+    Provider<CurrentUser> userProvider = Providers.of(
+        rsrc.getControl().getUser());
+    ACTION: for (UiAction.Description d : UiActions.from(
+        revisions, rsrc, userProvider)) {
+      ActionInfo actionInfo = new ActionInfo(d);
+      for (ActionVisitor visitor : visitors) {
+        if (!visitor.visit(d.getId(), actionInfo, changeInfo, revisionInfo)) {
+          continue ACTION;
+        }
       }
+      out.put(d.getId(), actionInfo);
     }
     return out;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java
index ad07e30..330ff7b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.change;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.FluentIterable;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.DiffWebLinkInfo;
 import com.google.gerrit.extensions.common.EditInfo;
@@ -490,7 +489,7 @@
       FileInfo r = new FileInfo();
       ChangeEdit edit = rsrc.getChangeEdit();
       Change change = edit.getChange();
-      FluentIterable<DiffWebLinkInfo> links =
+      List<DiffWebLinkInfo> links =
           webLinks.getDiffLinks(change.getProject().get(),
               change.getChangeId(),
               edit.getBasePatchSet().getPatchSetId(),
@@ -499,7 +498,7 @@
               0,
               edit.getRefName(),
               rsrc.getPath());
-      r.webLinks = links.isEmpty() ? null : links.toList();
+      r.webLinks = links.isEmpty() ? null : links;
       return r;
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
index e418364..80485f9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
@@ -47,6 +47,7 @@
 import com.google.common.collect.HashMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.common.collect.LinkedHashMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
@@ -54,6 +55,7 @@
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Table;
+import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
@@ -75,6 +77,7 @@
 import com.google.gerrit.extensions.common.PushCertificateInfo;
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.common.VotingRangeInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.config.DownloadCommand;
 import com.google.gerrit.extensions.config.DownloadScheme;
@@ -89,6 +92,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GpgException;
@@ -120,6 +124,7 @@
 import com.google.inject.assistedinject.AssistedInject;
 
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -192,6 +197,7 @@
   private final ChangeResource.Factory changeResourceFactory;
   private final ChangeKindCache changeKindCache;
   private final ChangeIndexCollection indexes;
+  private final ApprovalsUtil approvalsUtil;
 
   private boolean lazyLoad = true;
   private AccountLoader accountLoader;
@@ -221,6 +227,7 @@
       ChangeResource.Factory changeResourceFactory,
       ChangeKindCache changeKindCache,
       ChangeIndexCollection indexes,
+      ApprovalsUtil approvalsUtil,
       @Assisted Set<ListChangesOption> options) {
     this.db = db;
     this.labelNormalizer = ln;
@@ -244,6 +251,7 @@
     this.changeResourceFactory = changeResourceFactory;
     this.changeKindCache = changeKindCache;
     this.indexes = indexes;
+    this.approvalsUtil = approvalsUtil;
     this.options = options.isEmpty()
         ? EnumSet.noneOf(ListChangesOption.class)
         : EnumSet.copyOf(options);
@@ -538,8 +546,10 @@
     }
     finish(out);
 
+    // This block must come after the ChangeInfo is mostly populated, since
+    // it will be passed to ActionVisitors as-is.
     if (needRevisions) {
-      out.revisions = revisions(ctl, cd, src);
+      out.revisions = revisions(ctl, cd, src, out);
       if (out.revisions != null) {
         for (Map.Entry<String, RevisionInfo> entry : out.revisions.entrySet()) {
           if (entry.getValue().isCurrent) {
@@ -595,7 +605,7 @@
     LabelTypes labelTypes = ctl.getLabelTypes();
     Map<String, LabelWithStatus> withStatus = cd.change().getStatus().isOpen()
       ? labelsForOpenChange(ctl, cd, labelTypes, standard, detailed)
-      : labelsForClosedChange(cd, labelTypes, standard, detailed);
+      : labelsForClosedChange(ctl, cd, labelTypes, standard, detailed);
     return ImmutableMap.copyOf(
         Maps.transformValues(withStatus, LabelWithStatus::label));
   }
@@ -715,6 +725,8 @@
     for (Account.Id accountId : allUsers) {
       IdentifiedUser user = userFactory.create(accountId);
       ChangeControl ctl = baseCtrl.forUser(user);
+      Map<String, VotingRangeInfo> pvr =
+        getPermittedVotingRanges(permittedLabels(ctl, cd));
       for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
         LabelType lt = ctl.getLabelTypes().byLabel(e.getKey());
         if (lt == null) {
@@ -723,6 +735,8 @@
           continue;
         }
         Integer value;
+        VotingRangeInfo permittedVotingRange =
+          pvr.getOrDefault(lt.getName(), null);
         String tag = null;
         Timestamp date = null;
         PatchSetApproval psa = current.get(accountId, lt.getName());
@@ -746,19 +760,53 @@
           value = labelNormalizer.canVote(ctl, lt, accountId) ? 0 : null;
         }
         addApproval(e.getValue().label(),
-            approvalInfo(accountId, value, tag, date));
+            approvalInfo(accountId, value, permittedVotingRange, tag, date));
       }
     }
   }
 
+  private Map<String, VotingRangeInfo> getPermittedVotingRanges(
+      Map<String, Collection<String>> permittedLabels) {
+    Map<String, VotingRangeInfo> permittedVotingRanges =
+      Maps.newHashMapWithExpectedSize(permittedLabels.size());
+    for (String label : permittedLabels.keySet()) {
+      List<Integer> permittedVotingRange = permittedLabels.get(label)
+        .stream()
+        .map(this::parseRangeValue)
+        .filter(java.util.Objects::nonNull)
+        .sorted()
+        .collect(toList());
+
+      if (permittedVotingRange.isEmpty()) {
+        permittedVotingRanges.put(label, null);
+      } else {
+        int minPermittedValue = permittedVotingRange.get(0);
+        int maxPermittedValue = Iterables.getLast(permittedVotingRange);
+        permittedVotingRanges.put(label,
+          new VotingRangeInfo(minPermittedValue, maxPermittedValue));
+      }
+    }
+    return permittedVotingRanges;
+  }
+
+  private Integer parseRangeValue(String value) {
+    if (value.startsWith("+")) {
+      value = value.substring(1);
+    } else if (value.startsWith(" ")) {
+      value = value.trim();
+    }
+    return Ints.tryParse(value);
+  }
+
   private Timestamp getSubmittedOn(ChangeData cd)
       throws OrmException {
     Optional<PatchSetApproval> s = cd.getSubmitApproval();
     return s.isPresent() ? s.get().getGranted() : null;
   }
 
-  private Map<String, LabelWithStatus> labelsForClosedChange(ChangeData cd,
-      LabelTypes labelTypes, boolean standard, boolean detailed)
+  private Map<String, LabelWithStatus> labelsForClosedChange(
+      ChangeControl baseCtrl, ChangeData cd, LabelTypes labelTypes,
+      boolean standard, boolean detailed)
       throws OrmException {
     Set<Account.Id> allUsers = new HashSet<>();
     if (detailed) {
@@ -815,17 +863,21 @@
     }
 
     if (detailed) {
-      labels.entrySet().stream().forEach(
-          e -> setLabelValues(labelTypes.byLabel(e.getKey()), e.getValue()));
+      labels.entrySet().stream()
+          .filter(e -> labelTypes.byLabel(e.getKey()) != null)
+          .forEach(e -> setLabelValues(labelTypes.byLabel(e.getKey()),
+              e.getValue()));
     }
 
     for (Account.Id accountId : allUsers) {
       Map<String, ApprovalInfo> byLabel =
           Maps.newHashMapWithExpectedSize(labels.size());
-
+      Map<String, VotingRangeInfo> pvr = Collections.emptyMap();
       if (detailed) {
+        ChangeControl ctl = baseCtrl.forUser(userFactory.create(accountId));
+        pvr = getPermittedVotingRanges(permittedLabels(ctl, cd));
         for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) {
-          ApprovalInfo ai = approvalInfo(accountId, 0, null, null);
+          ApprovalInfo ai = approvalInfo(accountId, 0, null, null, null);
           byLabel.put(entry.getKey(), ai);
           addApproval(entry.getValue().label(), ai);
         }
@@ -840,6 +892,7 @@
         ApprovalInfo info = byLabel.get(type.getName());
         if (info != null) {
           info.value = Integer.valueOf(val);
+          info.permittedVotingRange = pvr.getOrDefault(type.getName(), null);
           info.date = psa.getGranted();
           info.tag = psa.getTag();
           if (psa.isPostSubmit()) {
@@ -856,17 +909,18 @@
     return labels;
   }
 
-  private ApprovalInfo approvalInfo(Account.Id id, Integer value, String tag,
-      Timestamp date) {
-    ApprovalInfo ai = getApprovalInfo(id, value, tag, date);
+  private ApprovalInfo approvalInfo(Account.Id id, Integer value,
+      VotingRangeInfo permittedVotingRange, String tag, Timestamp date) {
+    ApprovalInfo ai = getApprovalInfo(id, value, permittedVotingRange, tag, date);
     accountLoader.put(ai);
     return ai;
   }
 
-  public static ApprovalInfo getApprovalInfo(
-      Account.Id id, Integer value, String tag, Timestamp date) {
+  public static ApprovalInfo getApprovalInfo(Account.Id id, Integer value,
+      VotingRangeInfo permittedVotingRange, String tag, Timestamp date) {
     ApprovalInfo ai = new ApprovalInfo(id.get());
     ai.value = value;
+    ai.permittedVotingRange = permittedVotingRange;
     ai.date = date;
     ai.tag = tag;
     return ai;
@@ -889,10 +943,12 @@
 
   private Map<String, Collection<String>> permittedLabels(ChangeControl ctl, ChangeData cd)
       throws OrmException {
-    if (ctl == null) {
+    if (ctl == null || !ctl.getUser().isIdentifiedUser()) {
       return null;
     }
 
+    Map<String, Short> labels = null;
+    boolean isMerged = ctl.getChange().getStatus() == Change.Status.MERGED;
     LabelTypes labelTypes = ctl.getLabelTypes();
     SetMultimap<String, String> permitted = LinkedHashMultimap.create();
     for (SubmitRecord rec : submitRecords(cd)) {
@@ -901,12 +957,20 @@
       }
       for (SubmitRecord.Label r : rec.labels) {
         LabelType type = labelTypes.byLabel(r.label);
-        if (type == null) {
+        if (type == null || (isMerged && !type.allowPostSubmit())) {
           continue;
         }
         PermissionRange range = ctl.getRange(Permission.forLabel(r.label));
         for (LabelValue v : type.getValues()) {
-          if (range.contains(v.getValue())) {
+          boolean ok = range.contains(v.getValue());
+          if (isMerged) {
+            if (labels == null) {
+              labels = currentLabels(ctl);
+            }
+            short prev = labels.getOrDefault(type.getName(), (short) 0);
+            ok &= v.getValue() >= prev;
+          }
+          if (ok) {
             permitted.put(r.label, v.formatValue());
           }
         }
@@ -926,6 +990,17 @@
     return permitted.asMap();
   }
 
+  private Map<String, Short> currentLabels(ChangeControl ctl)
+      throws OrmException {
+    Map<String, Short> result = new HashMap<>();
+    for (PatchSetApproval psa : approvalsUtil.byPatchSetUser(
+        db.get(), ctl, ctl.getChange().currentPatchSetId(),
+        ctl.getUser().getAccountId())) {
+      result.put(psa.getLabel(), psa.getValue());
+    }
+    return result;
+  }
+
   private Collection<ChangeMessageInfo> messages(ChangeControl ctl, ChangeData cd,
       Map<PatchSet.Id, PatchSet> map)
       throws OrmException {
@@ -996,15 +1071,17 @@
   }
 
   private Map<String, RevisionInfo> revisions(ChangeControl ctl, ChangeData cd,
-      Map<PatchSet.Id, PatchSet> map) throws PatchListNotAvailableException,
-      GpgException, OrmException, IOException {
+      Map<PatchSet.Id, PatchSet> map, ChangeInfo changeInfo)
+      throws PatchListNotAvailableException, GpgException, OrmException,
+      IOException {
     Map<String, RevisionInfo> res = new LinkedHashMap<>();
     try (Repository repo = openRepoIfNecessary(ctl)) {
       for (PatchSet in : map.values()) {
         if ((has(ALL_REVISIONS)
             || in.getId().equals(ctl.getChange().currentPatchSetId()))
             && ctl.isPatchVisible(in, db.get())) {
-          res.put(in.getRevision().get(), toRevisionInfo(ctl, cd, in, repo, false));
+          res.put(in.getRevision().get(),
+              toRevisionInfo(ctl, cd, in, repo, false, changeInfo));
         }
       }
       return res;
@@ -1045,16 +1122,16 @@
     accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
     try (Repository repo = openRepoIfNecessary(ctl)) {
       RevisionInfo rev = toRevisionInfo(
-          ctl, changeDataFactory.create(db.get(), ctl), in, repo, true);
+          ctl, changeDataFactory.create(db.get(), ctl), in, repo, true, null);
       accountLoader.fill();
       return rev;
     }
   }
 
   private RevisionInfo toRevisionInfo(ChangeControl ctl, ChangeData cd,
-      PatchSet in, @Nullable Repository repo, boolean fillCommit)
-      throws PatchListNotAvailableException, GpgException, OrmException,
-      IOException {
+      PatchSet in, @Nullable Repository repo, boolean fillCommit,
+      @Nullable ChangeInfo changeInfo) throws PatchListNotAvailableException,
+      GpgException, OrmException, IOException {
     Change c = ctl.getChange();
     RevisionInfo out = new RevisionInfo();
     out.isCurrent = in.getId().equals(c.currentPatchSetId());
@@ -1080,9 +1157,15 @@
           out.commit = toCommit(ctl, rw, commit, has(WEB_LINKS), fillCommit);
         }
         if (addFooters) {
+          Ref ref = repo.exactRef(ctl.getChange().getDest().get());
+          RevCommit mergeTip = null;
+          if (ref != null){
+            mergeTip = rw.parseCommit(ref.getObjectId());
+            rw.parseBody(mergeTip);
+          }
           out.commitWithFooters = mergeUtilFactory
               .create(projectCache.get(project))
-              .createCherryPickCommitMessage(commit, ctl, in.getId());
+              .createCommitMessageOnSubmit(commit, mergeTip, ctl, in.getId());
         }
       }
     }
@@ -1097,7 +1180,7 @@
         && has(CURRENT_ACTIONS)
         && userProvider.get().isIdentifiedUser()) {
 
-      actionJson.addRevisionActions(out,
+      actionJson.addRevisionActions(changeInfo, out,
           new RevisionResource(changeResourceFactory.create(ctl), in));
     }
 
@@ -1128,9 +1211,9 @@
     info.message = commit.getFullMessage();
 
     if (addLinks) {
-      FluentIterable<WebLinkInfo> links =
+      List<WebLinkInfo> links =
           webLinks.getPatchSetLinks(project, commit.name());
-      info.webLinks = links.isEmpty() ? null : links.toList();
+      info.webLinks = links.isEmpty() ? null : links;
     }
 
     for (RevCommit parent : commit.getParents()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
index c0c0492..b3207e9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
@@ -230,7 +230,8 @@
         }
 
         if ((prior.getParentCount() != 1 || next.getParentCount() != 1)
-            && !onlyFirstParentChanged(prior, next)) {
+            && (!onlyFirstParentChanged(prior, next)
+                || prior.getParentCount() == 0)) {
           // Trivial rebases done by machine only work well on 1 parent.
           return ChangeKind.REWORK;
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeMessages.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeMessages.java
index 8236d3d..92b4150 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeMessages.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeMessages.java
@@ -23,7 +23,8 @@
   }
 
   public String revertChangeDefaultMessage;
-  public String reviewerNotFound;
+  public String reviewerNotFoundUser;
+  public String reviewerNotFoundUserOrGroup;
 
   public String groupIsNotAllowed;
   public String groupHasTooManyMembers;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java
index f07ee25..c5343e6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java
@@ -76,10 +76,10 @@
       Op op = new Op();
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
-      if (op.getDeletedAssignee() == null) {
-        return Response.none();
-      }
-      return Response.ok(AccountJson.toAccountInfo(op.getDeletedAssignee()));
+      Account deletedAssignee = op.getDeletedAssignee();
+      return deletedAssignee == null
+          ? Response.none()
+          : Response.ok(AccountJson.toAccountInfo(deletedAssignee));
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDescription.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDescription.java
new file mode 100644
index 0000000..b8a34d2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDescription.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetDescription implements RestReadView<RevisionResource> {
+  @Override
+  public String apply(RevisionResource rsrc) {
+    return Strings.nullToEmpty(rsrc.getPatchSet().getDescription());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java
index 5cf5895..d7c60f0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.common.base.MoreObjects;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
@@ -207,7 +206,7 @@
            ? resource.getRevision().getEdit().get().getRefName()
            : resource.getRevision().getPatchSet().getRefName();
 
-      FluentIterable<DiffWebLinkInfo> links =
+      List<DiffWebLinkInfo> links =
           webLinks.getDiffLinks(state.getProject().getName(),
               resource.getPatchKey().getParentKey().getParentKey().get(),
               basePatchSet != null ? basePatchSet.getId().get() : null,
@@ -216,7 +215,7 @@
               resource.getPatchKey().getParentKey().get(),
               revB,
               ps.getNewName());
-      result.webLinks = links.isEmpty() ? null : links.toList();
+      result.webLinks = links.isEmpty() ? null : links;
 
       if (!webLinksOnly) {
         if (ps.isBinary()) {
@@ -281,9 +280,9 @@
 
   private List<WebLinkInfo> getFileWebLinks(Project project, String rev,
       String file) {
-    FluentIterable<WebLinkInfo> links =
+    List<WebLinkInfo> links =
         webLinks.getFileLinks(project.getName(), rev, file);
-    return links.isEmpty() ? null : links.toList();
+    return links.isEmpty() ? null : links;
   }
 
   public GetDiff setBase(String base) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java
index 478d9c5..57e5cea 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java
@@ -59,7 +59,8 @@
   }
 
   @Override
-  public Response<Map<String, ActionInfo>> apply(RevisionResource rsrc) {
+  public Response<Map<String, ActionInfo>> apply(RevisionResource rsrc)
+      throws OrmException {
     return Response.withMustRevalidate(delegate.format(rsrc));
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
index 9ff9833..4d32222 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
@@ -104,6 +104,8 @@
     get(REVISION_KIND, "preview_submit").to(PreviewSubmit.class);
     post(REVISION_KIND, "submit").to(Submit.class);
     post(REVISION_KIND, "rebase").to(Rebase.class);
+    put(REVISION_KIND, "description").to(PutDescription.class);
+    get(REVISION_KIND, "description").to(GetDescription.class);
     get(REVISION_KIND, "patch").to(GetPatch.class);
     get(REVISION_KIND, "submit_type").to(TestSubmitType.Get.class);
     post(REVISION_KIND, "test.submit_rule").to(TestSubmitRule.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
index e711181..8cfeff0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
@@ -31,7 +31,6 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Ordering;
-import com.google.common.collect.Sets;
 import com.google.common.hash.HashCode;
 import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
@@ -106,6 +105,7 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
@@ -209,7 +209,7 @@
         reviewerInput.notify = NotifyHandling.NONE;
 
         PostReviewers.Addition result = postReviewers.prepareApplication(
-            revision.getChangeResource(), reviewerInput);
+            revision.getChangeResource(), reviewerInput, true);
         reviewerJsonResults.put(reviewerInput.reviewer, result.result);
         if (result.result.error != null) {
           hasError = true;
@@ -410,59 +410,97 @@
   }
 
   private <T extends CommentInput> void checkComments(RevisionResource revision,
-      Map<String, List<T>> in) throws BadRequestException, OrmException {
-    Iterator<? extends Map.Entry<String, List<T>>> mapItr =
-            in.entrySet().iterator();
-    Set<String> filePaths =
-        Sets.newHashSet(changeDataFactory.create(
-            db.get(), revision.getControl()).filePaths(
-                revision.getPatchSet()));
-    while (mapItr.hasNext()) {
-      Map.Entry<String, List<T>> ent = mapItr.next();
-      String path = ent.getKey();
-      if (!filePaths.contains(path) && !Patch.isMagic(path)) {
-        throw new BadRequestException(String.format(
-            "file %s not found in revision %s",
-            path, revision.getChange().currentPatchSetId()));
-      }
+      Map<String, List<T>> commentsPerPath)
+      throws BadRequestException, OrmException {
+    cleanUpComments(commentsPerPath);
+    ensureCommentsAreAddable(revision, commentsPerPath);
+  }
 
-      List<T> list = ent.getValue();
-      if (list == null) {
-        mapItr.remove();
+  private <T extends CommentInput> void cleanUpComments(
+      Map<String, List<T>> commentsPerPath) {
+    Iterator<List<T>> mapValueIterator = commentsPerPath.values().iterator();
+    while (mapValueIterator.hasNext()) {
+      List<T> comments = mapValueIterator.next();
+      if (comments == null) {
+        mapValueIterator.remove();
         continue;
       }
-      if (Patch.isMagic(path)) {
-        for (T comment : list) {
-          if (comment.side == Side.PARENT && comment.parent == null) {
-            throw new BadRequestException(
-                String.format("cannot comment on %s on auto-merge", path));
-          }
-        }
+
+      cleanUpComments(comments);
+
+      if (comments.isEmpty()) {
+        mapValueIterator.remove();
+      }
+    }
+  }
+
+  private <T extends CommentInput> void cleanUpComments(List<T> comments) {
+    Iterator<T> commentsIterator = comments.iterator();
+    while (commentsIterator.hasNext()) {
+      T comment = commentsIterator.next();
+      if (comment == null) {
+        commentsIterator.remove();
+        continue;
       }
 
-      Iterator<T> listItr = list.iterator();
-      while (listItr.hasNext()) {
-        T c = listItr.next();
-        if (c == null) {
-          listItr.remove();
-          continue;
-        }
-        if (c.line != null && c.line < 0) {
-          throw new BadRequestException(String.format(
-              "negative line number %d not allowed on %s",
-              c.line, path));
-        }
-        c.message = Strings.nullToEmpty(c.message).trim();
-        if (c.message.isEmpty()) {
-          listItr.remove();
-        }
+      comment.message = Strings.nullToEmpty(comment.message).trim();
+      if (comment.message.isEmpty()) {
+        commentsIterator.remove();
       }
-      if (list.isEmpty()) {
-        mapItr.remove();
+    }
+  }
+
+  private <T extends CommentInput> void ensureCommentsAreAddable(
+      RevisionResource revision, Map<String, List<T>> commentsPerPath)
+      throws OrmException, BadRequestException {
+    Set<String> revisionFilePaths = getAffectedFilePaths(revision);
+    for (Map.Entry<String, List<T>> entry : commentsPerPath.entrySet()) {
+      String path = entry.getKey();
+      PatchSet.Id patchSetId = revision.getChange().currentPatchSetId();
+      ensurePathRefersToAvailableOrMagicFile(path, revisionFilePaths,
+          patchSetId);
+
+      List<T> comments = entry.getValue();
+      for (T comment : comments) {
+        ensureLineIsNonNegative(comment.line, path);
+        ensureCommentNotOnMagicFilesOfAutoMerge(path, comment);
       }
     }
   }
 
+  private Set<String> getAffectedFilePaths(RevisionResource revision)
+      throws OrmException {
+    ChangeData changeData = changeDataFactory.create(db.get(),
+        revision.getControl());
+    return new HashSet<>(changeData.filePaths(revision.getPatchSet()));
+  }
+
+  private void ensurePathRefersToAvailableOrMagicFile(String path,
+      Set<String> availableFilePaths, PatchSet.Id patchSetId)
+      throws BadRequestException {
+    if (!availableFilePaths.contains(path) && !Patch.isMagic(path)) {
+      throw new BadRequestException(String.format(
+          "file %s not found in revision %s", path, patchSetId));
+    }
+  }
+
+  private void ensureLineIsNonNegative(Integer line, String path)
+      throws BadRequestException {
+    if (line != null && line < 0) {
+      throw new BadRequestException(String.format(
+          "negative line number %d not allowed on %s", line, path));
+    }
+  }
+
+  private <T extends CommentInput> void ensureCommentNotOnMagicFilesOfAutoMerge(
+      String path, T comment) throws BadRequestException {
+    if (Patch.isMagic(path) && comment.side == Side.PARENT
+        && comment.parent == null) {
+      throw new BadRequestException(
+          String.format("cannot comment on %s on auto-merge", path));
+    }
+  }
+
   private void checkRobotComments(RevisionResource revision,
       Map<String, List<RobotCommentInput>> in)
           throws BadRequestException, OrmException {
@@ -900,10 +938,27 @@
       // make it possible to take a merged change and make it no longer
       // submittable.
       List<PatchSetApproval> reduced = new ArrayList<>(ups.size() + del.size());
-      reduced.addAll(del);
+      List<String> disallowed =
+          new ArrayList<>(labelTypes.getLabelTypes().size());
+
+      for (PatchSetApproval psa : del) {
+        LabelType lt = checkNotNull(labelTypes.byLabel(psa.getLabel()));
+        String normName = lt.getName();
+        if (!lt.allowPostSubmit()) {
+          disallowed.add(normName);
+        }
+        Short prev = previous.get(normName);
+        if (prev != null && prev != 0) {
+          reduced.add(psa);
+        }
+      }
+
       for (PatchSetApproval psa : ups) {
         LabelType lt = checkNotNull(labelTypes.byLabel(psa.getLabel()));
         String normName = lt.getName();
+        if (!lt.allowPostSubmit()) {
+          disallowed.add(normName);
+        }
         Short prev = previous.get(normName);
         if (prev == null) {
           continue;
@@ -918,6 +973,12 @@
         }
       }
 
+      if (!disallowed.isEmpty()) {
+        throw new ResourceConflictException(
+            "Voting on labels disallowed after submit: "
+                + disallowed.stream().distinct().sorted()
+                    .collect(joining(", ")));
+      }
       if (!reduced.isEmpty()) {
         throw new ResourceConflictException(
             "Cannot reduce vote on labels for closed change: "
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
index f0af5da..0cdddca 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
@@ -147,7 +147,7 @@
       throw new BadRequestException("missing reviewer field");
     }
 
-    Addition addition = prepareApplication(rsrc, input);
+    Addition addition = prepareApplication(rsrc, input, true);
     if (addition.op == null) {
       return addition.result;
     }
@@ -161,18 +161,24 @@
     return addition.result;
   }
 
-  public Addition prepareApplication(ChangeResource rsrc, AddReviewerInput input)
-      throws OrmException, RestApiException, IOException {
+  public Addition prepareApplication(ChangeResource rsrc,
+      AddReviewerInput input, boolean allowGroup)
+          throws OrmException, RestApiException, IOException {
     Account.Id accountId;
     try {
       accountId = accounts.parse(input.reviewer).getAccountId();
     } catch (UnprocessableEntityException e) {
-      try {
-        return putGroup(rsrc, input);
-      } catch (UnprocessableEntityException e2) {
-        throw new UnprocessableEntityException(MessageFormat
-            .format(ChangeMessages.get().reviewerNotFound, input.reviewer));
+      if (allowGroup) {
+        try {
+          return putGroup(rsrc, input);
+        } catch (UnprocessableEntityException e2) {
+          throw new UnprocessableEntityException(MessageFormat.format(
+              ChangeMessages.get().reviewerNotFoundUserOrGroup,
+              input.reviewer));
+        }
       }
+      throw new UnprocessableEntityException(MessageFormat
+          .format(ChangeMessages.get().reviewerNotFoundUser, input.reviewer));
     }
     return putAccount(input.reviewer, reviewerFactory.create(rsrc, accountId),
         input.state(), input.notify);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutAssignee.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutAssignee.java
index 5002436..271fd33 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutAssignee.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutAssignee.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.base.Strings;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
@@ -65,7 +64,7 @@
     if (!rsrc.getControl().canEditAssignee()) {
       throw new AuthException("Changing Assignee not permitted");
     }
-    if (Strings.isNullOrEmpty(input.assignee)) {
+    if (input.assignee == null || input.assignee.trim().isEmpty()) {
       throw new BadRequestException("missing assignee field");
     }
 
@@ -91,7 +90,7 @@
     reviewerInput.state = ReviewerState.CC;
     reviewerInput.confirmed = true;
     reviewerInput.notify = NotifyHandling.NONE;
-    return postReviewers.prepareApplication(rsrc, reviewerInput);
+    return postReviewers.prepareApplication(rsrc, reviewerInput, false);
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java
new file mode 100644
index 0000000..2a32652
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java
@@ -0,0 +1,132 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.UpdateException;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import java.util.Collections;
+
+@Singleton
+public class PutDescription implements RestModifyView<RevisionResource,
+    PutDescription.Input>, UiAction<RevisionResource> {
+  private final Provider<ReviewDb> dbProvider;
+  private final ChangeMessagesUtil cmUtil;
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final PatchSetUtil psUtil;
+
+  public static class Input {
+    @DefaultInput
+    public String description;
+  }
+
+  @Inject
+  PutDescription(Provider<ReviewDb> dbProvider,
+      ChangeMessagesUtil cmUtil,
+      BatchUpdate.Factory batchUpdateFactory,
+      PatchSetUtil psUtil) {
+    this.dbProvider = dbProvider;
+    this.cmUtil = cmUtil;
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.psUtil = psUtil;
+  }
+
+  @Override
+  public Response<String> apply(RevisionResource rsrc, Input input)
+      throws UpdateException, RestApiException {
+    ChangeControl ctl = rsrc.getControl();
+    if (!ctl.canEditDescription()) {
+      throw new AuthException("changing description not permitted");
+    }
+    Op op =
+        new Op(input != null ? input : new Input(), rsrc.getPatchSet().getId());
+    try (BatchUpdate u = batchUpdateFactory.create(dbProvider.get(),
+        rsrc.getChange().getProject(), ctl.getUser(), TimeUtil.nowTs())) {
+      u.addOp(rsrc.getChange().getId(), op);
+      u.execute();
+    }
+    return Strings.isNullOrEmpty(op.newDescription) ? Response.none()
+        : Response.ok(op.newDescription);
+  }
+
+  private class Op extends BatchUpdate.Op {
+    private final Input input;
+    private final PatchSet.Id psId;
+
+    private String oldDescription;
+    private String newDescription;
+
+    Op(Input input, PatchSet.Id psId) {
+      this.input = input;
+      this.psId = psId;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws OrmException {
+      PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+      ChangeUpdate update = ctx.getUpdate(psId);
+      newDescription = Strings.nullToEmpty(input.description);
+      oldDescription = Strings.nullToEmpty(ps.getDescription());
+      if (oldDescription.equals(newDescription)) {
+        return false;
+      }
+      String summary;
+      if (oldDescription.isEmpty()) {
+        summary = "Description set to \"" + newDescription + "\"";
+      } else if (newDescription.isEmpty()) {
+        summary = "Description \"" + oldDescription + "\" removed";
+      } else {
+        summary = "Description changed to \"" + newDescription + "\"";
+      }
+
+      ps.setDescription(newDescription);
+      update.setPsDescription(newDescription);
+
+      ctx.getDb().patchSets().update(Collections.singleton(ps));
+
+      ChangeMessage cmsg =
+          ChangeMessagesUtil.newMessage(ctx.getDb(), psId, ctx.getUser(),
+              ctx.getWhen(), summary, ChangeMessagesUtil.TAG_SET_DESCRIPTION);
+      cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+      return true;
+    }
+  }
+
+  @Override
+  public UiAction.Description getDescription(RevisionResource rsrc) {
+    return new UiAction.Description().setLabel("Edit Description")
+        .setVisible(rsrc.getControl().canEditDescription());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
index d39f4fc..1e2bb4b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -68,6 +68,7 @@
   private CommitValidators.Policy validate;
   private boolean forceContentMerge;
   private boolean copyApprovals = true;
+  private boolean detailedCommitMessage;
   private boolean postMessage = true;
 
   private RevCommit rebasedCommit;
@@ -118,6 +119,12 @@
     return this;
   }
 
+  public RebaseChangeOp setDetailedCommitMessage(
+      boolean detailedCommitMessage) {
+    this.detailedCommitMessage = detailedCommitMessage;
+    return this;
+  }
+
   public RebaseChangeOp setPostMessage(boolean postMessage) {
     this.postMessage = postMessage;
     return this;
@@ -134,6 +141,7 @@
     RevWalk rw = ctx.getRevWalk();
     RevCommit original = rw.parseCommit(ObjectId.fromString(oldRev.get()));
     rw.parseBody(original);
+
     RevCommit baseCommit;
     if (baseCommitish != null) {
        baseCommit = rw.parseCommit(ctx.getRepository().resolve(baseCommitish));
@@ -143,7 +151,16 @@
            ctx.getRepository(), ctx.getRevWalk()));
     }
 
-    rebasedCommit = rebaseCommit(ctx, original, baseCommit);
+    String newCommitMessage;
+    if (detailedCommitMessage) {
+      rw.parseBody(baseCommit);
+      newCommitMessage = newMergeUtil().createCommitMessageOnSubmit(original,
+          baseCommit, ctl, originalPatchSet.getId());
+    } else {
+      newCommitMessage = original.getFullMessage();
+    }
+
+    rebasedCommit = rebaseCommit(ctx, original, baseCommit, newCommitMessage);
 
     RevId baseRevId = new RevId((baseCommitish != null) ? baseCommitish
         : ObjectId.toString(baseCommit.getId()));
@@ -222,9 +239,9 @@
    * @throws MergeConflictException the rebase failed due to a merge conflict.
    * @throws IOException the merge failed for another reason.
    */
-  private RevCommit rebaseCommit(RepoContext ctx, RevCommit original,
-      ObjectId base) throws ResourceConflictException, MergeConflictException,
-      IOException {
+  private RevCommit rebaseCommit(
+      RepoContext ctx, RevCommit original, ObjectId base, String commitMessage)
+      throws ResourceConflictException, IOException {
     RevCommit parentCommit = original.getParent(0);
 
     if (base.equals(parentCommit)) {
@@ -245,7 +262,7 @@
     cb.setTreeId(merger.getResultTreeId());
     cb.setParentId(base);
     cb.setAuthor(original.getAuthorIdent());
-    cb.setMessage(original.getFullMessage());
+    cb.setMessage(commitMessage);
     if (committerIdent != null) {
       cb.setCommitter(committerIdent);
     } else {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java
index 39820b8..b9f4483 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java
@@ -99,7 +99,7 @@
   }
 
   private void rebuild(ChangeResource rsrc) throws ResourceNotFoundException,
-      ConfigInvalidException, OrmException, IOException {
+      OrmException, IOException {
     try {
       rebuilder.rebuild(db.get(), rsrc.getId());
     } catch (NoSuchChangeException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 7e868f2..acae738 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.audit.AuditModule;
 import com.google.gerrit.common.EventListener;
 import com.google.gerrit.common.UserScopedEventListener;
+import com.google.gerrit.extensions.api.changes.ActionVisitor;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
 import com.google.gerrit.extensions.auth.oauth.OAuthTokenEncrypter;
@@ -104,7 +105,9 @@
 import com.google.gerrit.server.events.EventFactory;
 import com.google.gerrit.server.events.EventsMetrics;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.AbandonOp;
 import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.git.EmailMerge;
 import com.google.gerrit.server.git.GitModule;
 import com.google.gerrit.server.git.GitModules;
@@ -340,6 +343,7 @@
     DynamicSet.bind(binder(), EventListener.class).to(EventsMetrics.class);
     DynamicSet.setOf(binder(), UserScopedEventListener.class);
     DynamicSet.setOf(binder(), CommitValidationListener.class);
+    DynamicSet.setOf(binder(), ChangeMessageModifier.class);
     DynamicSet.setOf(binder(), RefOperationValidationListener.class);
     DynamicSet.setOf(binder(), MergeValidationListener.class);
     DynamicSet.setOf(binder(), ProjectCreationValidationListener.class);
@@ -368,15 +372,18 @@
     DynamicSet.setOf(binder(), WebUiPlugin.class);
     DynamicItem.itemOf(binder(), AccountPatchReviewStore.class);
     DynamicSet.setOf(binder(), AssigneeValidationListener.class);
+    DynamicSet.setOf(binder(), ActionVisitor.class);
 
     factory(UploadValidators.Factory.class);
     DynamicSet.setOf(binder(), UploadValidationListener.class);
 
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
+    DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeHasOperandFactory.class);
     install(new GitwebConfig.LegacyModule(cfg));
 
     bind(AnonymousUser.class);
 
+    factory(AbandonOp.Factory.class);
     factory(RefOperationValidators.Factory.class);
     factory(MergeValidators.Factory.class);
     factory(ProjectConfigValidator.Factory.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
index 5e4eb6f..162a6b1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -93,7 +93,7 @@
     for (Map.Entry<String, Short> e : approvals.entrySet()) {
       Integer value = e.getValue() != null ? Integer.valueOf(e.getValue()) : null;
       result.put(e.getKey(),
-          ChangeJson.getApprovalInfo(a.getId(), value, null, ts));
+          ChangeJson.getApprovalInfo(a.getId(), value, null, null, ts));
     }
     return result;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/webui/UiActions.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/webui/UiActions.java
index 3dbd1e3..fe261e9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/webui/UiActions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/webui/UiActions.java
@@ -39,17 +39,19 @@
     return UiAction.Description::isEnabled;
   }
 
-  public static <R extends RestResource> Iterable<UiAction.Description> from(
-      RestCollection<?, R> collection,
-      R resource,
-      Provider<CurrentUser> userProvider) {
+  public static <R extends RestResource> FluentIterable<UiAction.Description>
+      from(
+          RestCollection<?, R> collection,
+          R resource,
+          Provider<CurrentUser> userProvider) {
     return from(collection.views(), resource, userProvider);
   }
 
-  public static <R extends RestResource> Iterable<UiAction.Description> from(
-      DynamicMap<RestView<R>> views,
-      final R resource,
-      final Provider<CurrentUser> userProvider) {
+  public static <R extends RestResource> FluentIterable<UiAction.Description>
+      from(
+          DynamicMap<RestView<R>> views,
+          R resource,
+          Provider<CurrentUser> userProvider) {
     return FluentIterable.from(views)
         .transform((DynamicMap.Entry<RestView<R>> e) -> {
               int d = e.getExportName().indexOf('.');
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/AbandonOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/AbandonOp.java
new file mode 100644
index 0000000..d4751ea
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/AbandonOp.java
@@ -0,0 +1,141 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.extensions.events.ChangeAbandoned;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.BatchUpdate.Context;
+import com.google.gerrit.server.mail.send.AbandonedSender;
+import com.google.gerrit.server.mail.send.ReplyToChangeSender;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class AbandonOp extends BatchUpdate.Op {
+  private static final Logger log = LoggerFactory.getLogger(AbandonOp.class);
+
+  private final AbandonedSender.Factory abandonedSenderFactory;
+  private final ChangeMessagesUtil cmUtil;
+  private final PatchSetUtil psUtil;
+  private final ChangeAbandoned changeAbandoned;
+
+  private final String msgTxt;
+  private final NotifyHandling notifyHandling;
+  private final Account account;
+
+  private Change change;
+  private PatchSet patchSet;
+  private ChangeMessage message;
+
+  public interface Factory {
+    AbandonOp create(
+        @Assisted @Nullable Account account,
+        @Assisted @Nullable String msgTxt,
+        @Assisted NotifyHandling notifyHandling);
+  }
+
+  @AssistedInject
+  AbandonOp(
+      AbandonedSender.Factory abandonedSenderFactory,
+      ChangeMessagesUtil cmUtil,
+      PatchSetUtil psUtil,
+      ChangeAbandoned changeAbandoned,
+      @Assisted @Nullable Account account,
+      @Assisted @Nullable String msgTxt,
+      @Assisted NotifyHandling notifyHandling) {
+    this.abandonedSenderFactory = abandonedSenderFactory;
+    this.cmUtil = cmUtil;
+    this.psUtil = psUtil;
+    this.changeAbandoned = changeAbandoned;
+
+    this.account = account;
+    this.msgTxt = Strings.nullToEmpty(msgTxt);
+    this.notifyHandling = notifyHandling;
+  }
+
+  @Nullable
+  public Change getChange() {
+    return change;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws OrmException, ResourceConflictException {
+    change = ctx.getChange();
+    PatchSet.Id psId = change.currentPatchSetId();
+    ChangeUpdate update = ctx.getUpdate(psId);
+    if (!change.getStatus().isOpen()) {
+      throw new ResourceConflictException("change is " + status(change));
+    } else if (change.getStatus() == Change.Status.DRAFT) {
+      throw new ResourceConflictException("draft changes cannot be abandoned");
+    }
+    patchSet = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+    change.setStatus(Change.Status.ABANDONED);
+    change.setLastUpdatedOn(ctx.getWhen());
+
+    update.setStatus(change.getStatus());
+    message = newMessage(ctx);
+    cmUtil.addChangeMessage(ctx.getDb(), update, message);
+    return true;
+  }
+
+  private ChangeMessage newMessage(ChangeContext ctx) throws OrmException {
+    StringBuilder msg = new StringBuilder();
+    msg.append("Abandoned");
+    if (!Strings.nullToEmpty(msgTxt).trim().isEmpty()) {
+      msg.append("\n\n");
+      msg.append(msgTxt.trim());
+    }
+
+    return ChangeMessagesUtil.newMessage(
+        ctx, msg.toString(), ChangeMessagesUtil.TAG_ABANDON);
+  }
+
+  @Override
+  public void postUpdate(Context ctx) throws OrmException {
+    try {
+      ReplyToChangeSender cm =
+          abandonedSenderFactory.create(ctx.getProject(), change.getId());
+      if (account != null) {
+        cm.setFrom(account.getId());
+      }
+      cm.setChangeMessage(message.getMessage(), ctx.getWhen());
+      cm.setNotify(notifyHandling);
+      cm.send();
+    } catch (Exception e) {
+      log.error("Cannot email update for change " + change.getId(), e);
+    }
+    changeAbandoned.fire(
+        change, patchSet, account, msgTxt, ctx.getWhen(), notifyHandling);
+  }
+
+  private static String status(Change change) {
+    return change != null ? change.getStatus().name().toLowerCase() : "deleted";
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java
index 3953f27..bc2b4df 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java
@@ -1029,10 +1029,14 @@
     }
 
     private ChangeContext newChangeContext(ReviewDb db, Repository repo,
-        RevWalk rw, Change.Id id) throws Exception {
+        RevWalk rw, Change.Id id) throws OrmException, NoSuchChangeException {
       Change c = newChanges.get(id);
       if (c == null) {
         c = ChangeNotes.readOneReviewDbChange(db, id);
+        if (c == null) {
+          logDebug("Failed to get change {} from unwrapped db", id);
+          throw new NoSuchChangeException(id);
+        }
       }
       // Pass in preloaded change to controlFor, to avoid:
       //  - reading from a db that does not belong to this update
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeMessageModifier.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeMessageModifier.java
new file mode 100644
index 0000000..75911f3f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeMessageModifier.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.reviewdb.client.Branch;
+
+import org.eclipse.jgit.revwalk.RevCommit;
+
+/**
+ * Allows to modify the commit message for new commits generated by Rebase
+ * Always submit strategy.
+ *
+ * Invoked by Gerrit when all information about new commit is already known such
+ * as parent(s), tree hash, etc, but commit's message can still be modified.
+ */
+@ExtensionPoint
+public interface ChangeMessageModifier {
+
+  /**
+   * Implementation must return non-Null commit message.
+   *
+   * mergeTip and original commit are guaranteed to have their body parsed,
+   * meaning that their commit messages and footers can be accessed.
+   *
+   * @param newCommitMessage the new commit message that was result of either
+   *        <ul>
+   *        <li>{@link MergeUtil#createDetailedCommitMessage} called before</li>
+   *        <li>other extensions or plugins implementing the same point and
+   *        called before.</li>
+   *        </ul>
+   * @param original the commit of the change being submitted. <b>Note that its
+   *        commit message may be different than newCommitMessage argument.</b>
+   * @param mergeTip the current HEAD of the destination branch, which will be a
+   *        parent of a new commit being generated
+   * @param destination the branch onto which the change is being submitted
+   * @return a new not null commit message.
+   */
+  String onSubmit(String newCommitMessage, RevCommit original,
+      RevCommit mergeTip, Branch.NameKey destination);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManager.java
index 29e14ec..1724808 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManager.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.git;
 
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.inject.ImplementedBy;
 import com.google.inject.Singleton;
 
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -30,6 +31,7 @@
  * registered in Guice so they are globally available within the server
  * environment.
  */
+@ImplementedBy(value = LocalDiskRepositoryManager.class)
 public interface GitRepositoryManager {
   /**
    * Get (or open) a repository by name.
@@ -61,31 +63,4 @@
 
   /** @return set of all known projects, sorted by natural NameKey order. */
   SortedSet<Project.NameKey> list();
-
-  /**
-   * Read the {@code GIT_DIR/description} file for gitweb.
-   * <p>
-   * NB: This code should really be in JGit, as a member of the Repository
-   * object. Until it moves there, its here.
-   *
-   * @param name the repository name, relative to the base directory.
-   * @return description text; null if no description has been configured.
-   * @throws RepositoryNotFoundException the named repository does not exist.
-   * @throws IOException the description file exists, but is not readable by
-   *         this process.
-   */
-  String getProjectDescription(Project.NameKey name)
-      throws RepositoryNotFoundException, IOException;
-
-  /**
-   * Set the {@code GIT_DIR/description} file for gitweb.
-   * <p>
-   * NB: This code should really be in JGit, as a member of the Repository
-   * object. Until it moves there, its here.
-   *
-   * @param name the repository name, relative to the base directory.
-   * @param description new description text for the repository.
-   */
-  void setProjectDescription(Project.NameKey name,
-      final String description);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
index ad5cf20..dc15a8b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
@@ -24,7 +24,6 @@
 import com.google.inject.Singleton;
 
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.internal.storage.file.LockFile;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ConfigConstants;
 import org.eclipse.jgit.lib.Constants;
@@ -35,13 +34,10 @@
 import org.eclipse.jgit.lib.StoredConfig;
 import org.eclipse.jgit.storage.file.WindowCacheConfig;
 import org.eclipse.jgit.util.FS;
-import org.eclipse.jgit.util.IO;
-import org.eclipse.jgit.util.RawParseUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.io.File;
-import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.nio.file.FileVisitOption;
 import java.nio.file.FileVisitResult;
@@ -58,19 +54,13 @@
 
 /** Manages Git repositories stored on the local filesystem. */
 @Singleton
-public class LocalDiskRepositoryManager implements GitRepositoryManager,
-    LifecycleListener {
+public class LocalDiskRepositoryManager implements GitRepositoryManager {
   private static final Logger log =
       LoggerFactory.getLogger(LocalDiskRepositoryManager.class);
 
-  private static final String UNNAMED =
-      "Unnamed repository; edit this file to name it for gitweb.";
-
   public static class Module extends LifecycleModule {
     @Override
     protected void configure() {
-      bind(GitRepositoryManager.class).to(LocalDiskRepositoryManager.class);
-      listener().to(LocalDiskRepositoryManager.class);
       listener().to(LocalDiskRepositoryManager.Lifecycle.class);
     }
   }
@@ -139,15 +129,6 @@
     namesUpdateLock = new ReentrantLock(true /* fair */);
   }
 
-  @Override
-  public void start() {
-    names = list();
-  }
-
-  @Override
-  public void stop() {
-  }
-
   /**
    * Return the basePath under which the specified project is stored.
    *
@@ -207,7 +188,8 @@
 
   @Override
   public Repository createRepository(Project.NameKey name)
-      throws RepositoryNotFoundException, RepositoryCaseMismatchException {
+      throws RepositoryNotFoundException, RepositoryCaseMismatchException,
+      IOException {
     Path path = getBasePath(name);
     if (isUnreasonableName(name)) {
       throw new RepositoryNotFoundException("Invalid name: " + name);
@@ -218,6 +200,10 @@
     if (dir != null) {
       // Already exists on disk, use the repository we found.
       //
+      Project.NameKey onDiskName = getProjectName(
+          path, dir.getCanonicalFile().toPath());
+      onCreateProject(onDiskName);
+
       loc = FileKey.exact(dir, FS.DETECTED);
 
       if (!names.contains(name)) {
@@ -274,66 +260,6 @@
     }
   }
 
-  @Override
-  public String getProjectDescription(final Project.NameKey name)
-      throws RepositoryNotFoundException, IOException {
-    try (Repository e = openRepository(name)) {
-      return getProjectDescription(e);
-    }
-  }
-
-  private String getProjectDescription(final Repository e) throws IOException {
-    final File d = new File(e.getDirectory(), "description");
-
-    String description;
-    try {
-      description = RawParseUtils.decode(IO.readFully(d));
-    } catch (FileNotFoundException err) {
-      return null;
-    }
-
-    if (description != null) {
-      description = description.trim();
-      if (description.isEmpty()) {
-        description = null;
-      }
-      if (UNNAMED.equals(description)) {
-        description = null;
-      }
-    }
-    return description;
-  }
-
-  @Override
-  public void setProjectDescription(Project.NameKey name, String description) {
-    // Update git's description file, in case gitweb is being used
-    //
-    try (Repository e = openRepository(name)) {
-      String old = getProjectDescription(e);
-      if ((old == null && description == null)
-          || (old != null && old.equals(description))) {
-        return;
-      }
-
-      LockFile f = new LockFile(new File(e.getDirectory(), "description"));
-      if (f.lock()) {
-        String d = description;
-        if (d != null) {
-          d = d.trim();
-          if (d.length() > 0) {
-            d += "\n";
-          }
-        } else {
-          d = "";
-        }
-        f.write(Constants.encode(d));
-        f.commit();
-      }
-    } catch (IOException e) {
-      log.error("Cannot update description for " + name, e);
-    }
-  }
-
   private boolean isUnreasonableName(final Project.NameKey nameKey) {
     final String name = nameKey.get();
 
@@ -362,15 +288,19 @@
   public SortedSet<Project.NameKey> list() {
     // The results of this method are cached by ProjectCacheImpl. Control only
     // enters here if the cache was flushed by the administrator to force
-    // scanning the filesystem. Don't rely on the cached names collection.
+    // scanning the filesystem.
+    // Don't rely on the cached names collection but update it to contain
+    // the set of found project names
+    ProjectVisitor visitor = new ProjectVisitor(basePath);
+    scanProjects(visitor);
+
     namesUpdateLock.lock();
     try {
-      ProjectVisitor visitor = new ProjectVisitor(basePath);
-      scanProjects(visitor);
-      return Collections.unmodifiableSortedSet(visitor.found);
+      names = Collections.unmodifiableSortedSet(visitor.found);
     } finally {
       namesUpdateLock.unlock();
     }
+    return names;
   }
 
   protected void scanProjects(ProjectVisitor visitor) {
@@ -383,6 +313,18 @@
     }
   }
 
+  private static Project.NameKey getProjectName(Path startFolder, Path p) {
+    String projectName = startFolder.relativize(p).toString();
+    if (File.separatorChar != '/') {
+      projectName = projectName.replace(File.separatorChar, '/');
+    }
+    if (projectName.endsWith(Constants.DOT_GIT_EXT)) {
+      int newLen = projectName.length() - Constants.DOT_GIT_EXT.length();
+      projectName = projectName.substring(0, newLen);
+    }
+    return new Project.NameKey(projectName);
+  }
+
   protected class ProjectVisitor extends SimpleFileVisitor<Path> {
     private final SortedSet<Project.NameKey> found = new TreeSet<>();
     private Path startFolder;
@@ -413,7 +355,7 @@
     }
 
     private void addProject(Path p) {
-      Project.NameKey nameKey = getProjectName(p);
+      Project.NameKey nameKey = getProjectName(startFolder, p);
       if (getBasePath(nameKey).equals(startFolder)) {
         if (isUnreasonableName(nameKey)) {
           log.warn(
@@ -423,17 +365,5 @@
         }
       }
     }
-
-    private Project.NameKey getProjectName(Path p) {
-      String projectName = startFolder.relativize(p).toString();
-      if (File.separatorChar != '/') {
-        projectName = projectName.replace(File.separatorChar, '/');
-      }
-      if (projectName.endsWith(Constants.DOT_GIT_EXT)) {
-        int newLen = projectName.length() - Constants.DOT_GIT_EXT.length();
-        projectName = projectName.substring(0, newLen);
-      }
-      return new Project.NameKey(projectName);
-    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/LockFailureException.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/LockFailureException.java
new file mode 100644
index 0000000..7380b0a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/LockFailureException.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import java.io.IOException;
+
+/** Thrown when updating a ref in Git fails with LOCK_FAILURE. */
+public class LockFailureException extends IOException {
+  private static final long serialVersionUID = 1L;
+
+  public LockFailureException(String message) {
+    super(message);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java
index 90edfb1..b97dbc6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.git;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
@@ -25,6 +26,7 @@
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -33,6 +35,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSet.Id;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
@@ -44,6 +47,7 @@
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
@@ -84,6 +88,7 @@
 import java.util.Iterator;
 import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Objects;
 import java.util.Set;
 
 /**
@@ -97,6 +102,32 @@
  */
 public class MergeUtil {
   private static final Logger log = LoggerFactory.getLogger(MergeUtil.class);
+
+  static class PluggableCommitMessageGenerator {
+    private final DynamicSet<ChangeMessageModifier> changeMessageModifiers;
+
+    @Inject
+    PluggableCommitMessageGenerator(
+        DynamicSet<ChangeMessageModifier> changeMessageModifiers) {
+      this.changeMessageModifiers = changeMessageModifiers;
+    }
+
+    public String generate(RevCommit original, RevCommit mergeTip,
+        ChangeControl ctl, String current) {
+      checkNotNull(original.getRawBuffer());
+      if (mergeTip != null) {
+        checkNotNull(mergeTip.getRawBuffer());
+      }
+      for (ChangeMessageModifier changeMessageModifier : changeMessageModifiers) {
+        current = changeMessageModifier.onSubmit(current, original,
+            mergeTip, ctl.getChange().getDest());
+        checkNotNull(current, changeMessageModifier.getClass().getName()
+            + ".OnSubmit returned null instead of new commit message");
+      }
+      return current;
+    }
+  }
+
   private static final String R_HEADS_MASTER =
       Constants.R_HEADS + Constants.MASTER;
 
@@ -122,25 +153,28 @@
   private final ProjectState project;
   private final boolean useContentMerge;
   private final boolean useRecursiveMerge;
+  private final PluggableCommitMessageGenerator commitMessageGenerator;
 
   @AssistedInject
   MergeUtil(@GerritServerConfig Config serverConfig,
-      final Provider<ReviewDb> db,
-      final IdentifiedUser.GenericFactory identifiedUserFactory,
-      @CanonicalWebUrl @Nullable final Provider<String> urlProvider,
-      final ApprovalsUtil approvalsUtil,
-      @Assisted final ProjectState project) {
+      Provider<ReviewDb> db,
+      IdentifiedUser.GenericFactory identifiedUserFactory,
+      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
+      ApprovalsUtil approvalsUtil,
+      PluggableCommitMessageGenerator commitMessageGenerator,
+      @Assisted ProjectState project) {
     this(serverConfig, db, identifiedUserFactory, urlProvider, approvalsUtil,
-        project, project.isUseContentMerge());
+        project, commitMessageGenerator, project.isUseContentMerge());
   }
 
   @AssistedInject
   MergeUtil(@GerritServerConfig Config serverConfig,
-      final Provider<ReviewDb> db,
-      final IdentifiedUser.GenericFactory identifiedUserFactory,
-      @CanonicalWebUrl @Nullable final Provider<String> urlProvider,
-      final ApprovalsUtil approvalsUtil,
-      @Assisted final ProjectState project,
+      Provider<ReviewDb> db,
+      IdentifiedUser.GenericFactory identifiedUserFactory,
+      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
+      ApprovalsUtil approvalsUtil,
+      @Assisted ProjectState project,
+      PluggableCommitMessageGenerator commitMessageGenerator,
       @Assisted boolean useContentMerge) {
     this.db = db;
     this.identifiedUserFactory = identifiedUserFactory;
@@ -149,6 +183,7 @@
     this.project = project;
     this.useContentMerge = useContentMerge;
     this.useRecursiveMerge = useRecursiveMerge(serverConfig);
+    this.commitMessageGenerator = commitMessageGenerator;
   }
 
   public CodeReviewCommit getFirstFastForward(
@@ -246,7 +281,24 @@
     return sb.toString();
   }
 
-  public String createCherryPickCommitMessage(RevCommit n, ChangeControl ctl,
+  /**
+   * Adds footers to existing commit message based on the state of the change.
+   *
+   * This adds the following footers if they are missing:
+   *
+   * <ul>
+   *   <li> Reviewed-on: <i>url</i></li>
+   *   <li> Reviewed-by | Tested-by | <i>Other-Label-Name</i>: <i>reviewer</i>
+   *   </li>
+   *   <li> Change-Id </li>
+   * </ul>
+   *
+   * @param n
+   * @param ctl
+   * @param psId
+   * @return new message
+   */
+  private String createDetailedCommitMessage(RevCommit n, ChangeControl ctl,
       PatchSet.Id psId) {
     Change c = ctl.getChange();
     final List<FooterLine> footers = n.getFooterLines();
@@ -350,12 +402,32 @@
         msgbuf.append('\n');
       }
     }
-
     return msgbuf.toString();
   }
 
-  public String createCherryPickCommitMessage(final CodeReviewCommit n) {
-    return createCherryPickCommitMessage(n, n.getControl(), n.getPatchsetId());
+  public String createCommitMessageOnSubmit(CodeReviewCommit n,
+      RevCommit mergeTip) {
+    return createCommitMessageOnSubmit(n, mergeTip, n.getControl(),
+        n.getPatchsetId());
+  }
+
+  /**
+   * Creates a commit message for a change, which can be customized by plugins.
+   *
+   * By default, adds footers to existing commit message based on the state of
+   * the change. Plugins implementing {@link ChangeMessageModifier} can modify
+   * the resulting commit message arbitrarily.
+   *
+   * @param n
+   * @param mergeTip
+   * @param ctl
+   * @param id
+   * @return new message
+   */
+  public String createCommitMessageOnSubmit(RevCommit n, RevCommit mergeTip,
+      ChangeControl ctl, Id id) {
+    return commitMessageGenerator.generate(n, mergeTip, ctl,
+        createDetailedCommitMessage(n, ctl, id));
   }
 
   private static boolean isCodeReview(LabelId id) {
@@ -679,7 +751,10 @@
       rw.sort(RevSort.REVERSE, true);
       rw.markStart(mergeTip);
       for (RevCommit c : alreadyAccepted) {
-        rw.markUninteresting(c);
+        // If branch was not created by this submit.
+        if (!Objects.equals(c, mergeTip)) {
+          rw.markUninteresting(c);
+        }
       }
 
       CodeReviewCommit c;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
index dffcf30..db739b1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
@@ -35,9 +35,6 @@
     protected void configure() {
       bind(GitRepositoryManager.class).to(
           MultiBaseLocalDiskRepositoryManager.class);
-      bind(LocalDiskRepositoryManager.class).to(
-          MultiBaseLocalDiskRepositoryManager.class);
-      listener().to(MultiBaseLocalDiskRepositoryManager.class);
       listener().to(MultiBaseLocalDiskRepositoryManager.Lifecycle.class);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiProgressMonitor.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiProgressMonitor.java
index d081fe6..9810fec 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiProgressMonitor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiProgressMonitor.java
@@ -81,7 +81,7 @@
     @Override
     public void update(final int completed) {
       boolean w = false;
-      synchronized (this) {
+      synchronized (MultiProgressMonitor.this) {
         count += completed;
         if (total != UNKNOWN) {
           int percent = count * 100 / total;
@@ -124,8 +124,10 @@
       return false;
     }
 
-    public synchronized int getCount() {
-      return count;
+    public int getCount() {
+      synchronized(MultiProgressMonitor.this) {
+        return count;
+      }
     }
   }
 
@@ -319,7 +321,7 @@
     if (!tasks.isEmpty()) {
       boolean first = true;
       for (Task t : tasks) {
-        int count = t.count;
+        int count = t.getCount();
         if (count == 0) {
           continue;
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
index 4f143ed..f3ed9f2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
@@ -144,6 +144,7 @@
   private static final String KEY_FUNCTION = "function";
   private static final String KEY_DEFAULT_VALUE = "defaultValue";
   private static final String KEY_COPY_MIN_SCORE = "copyMinScore";
+  private static final String KEY_ALLOW_POST_SUBMIT = "allowPostSubmit";
   private static final String KEY_COPY_MAX_SCORE = "copyMaxScore";
   private static final String KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE = "copyAllScoresOnMergeFirstParentUpdate";
   private static final String KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = "copyAllScoresOnTrivialRebase";
@@ -489,6 +490,13 @@
     if (p.getDescription() == null) {
       p.setDescription("");
     }
+
+    if (rc.getStringList(ACCESS, null, KEY_INHERIT_FROM).length > 1) {
+      // The config must not contain more than one parent to inherit from
+      // as there is no guarantee which of the parents would be used then.
+      error(new ValidationError(PROJECT_CONFIG,
+          "Cannot inherit from multiple projects"));
+    }
     p.setParentName(rc.getString(ACCESS, null, KEY_INHERIT_FROM));
 
     p.setUseContributorAgreements(getEnum(rc, RECEIVE, null, KEY_REQUIRE_CONTRIBUTOR_AGREEMENT, InheritableBoolean.INHERIT));
@@ -795,6 +803,9 @@
               KEY_DEFAULT_VALUE, dv, name)));
         }
       }
+      label.setAllowPostSubmit(
+          rc.getBoolean(LABEL, name, KEY_ALLOW_POST_SUBMIT,
+              LabelType.DEF_ALLOW_POST_SUBMIT));
       label.setCopyMinScore(
           rc.getBoolean(LABEL, name, KEY_COPY_MIN_SCORE,
               LabelType.DEF_COPY_MIN_SCORE));
@@ -1190,6 +1201,8 @@
       rc.setString(LABEL, name, KEY_FUNCTION, label.getFunctionName());
       rc.setInt(LABEL, name, KEY_DEFAULT_VALUE, label.getDefaultValue());
 
+      setBooleanConfigKey(rc, name, KEY_ALLOW_POST_SUBMIT, label.allowPostSubmit(),
+          LabelType.DEF_ALLOW_POST_SUBMIT);
       setBooleanConfigKey(rc, name, KEY_COPY_MIN_SCORE, label.isCopyMinScore(),
           LabelType.DEF_COPY_MIN_SCORE);
       setBooleanConfigKey(rc, name, KEY_COPY_MAX_SCORE, label.isCopyMaxScore(),
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReadOnlyRepository.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReadOnlyRepository.java
index 32faeac..2a16148 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReadOnlyRepository.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReadOnlyRepository.java
@@ -101,6 +101,11 @@
     return delegate.getReflogReader(refName);
   }
 
+  @Override
+  public String getGitwebDescription() throws IOException {
+    return delegate.getGitwebDescription();
+  }
+
   private static class RefDb extends RefDatabase {
     private final RefDatabase delegate;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/RebaseSorter.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/RebaseSorter.java
index 5952602..6448d06 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/RebaseSorter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/RebaseSorter.java
@@ -32,13 +32,15 @@
 public class RebaseSorter {
   private final CodeReviewRevWalk rw;
   private final RevFlag canMergeFlag;
-  private final Set<RevCommit> accepted;
+  private final RevCommit initialTip;
+  private final Set<RevCommit> alreadyAccepted;
 
-  public RebaseSorter(CodeReviewRevWalk rw, Set<RevCommit> alreadyAccepted,
-      RevFlag canMergeFlag) {
+  public RebaseSorter(CodeReviewRevWalk rw, RevCommit initialTip,
+      Set<RevCommit> alreadyAccepted, RevFlag canMergeFlag) {
     this.rw = rw;
     this.canMergeFlag = canMergeFlag;
-    this.accepted = alreadyAccepted;
+    this.initialTip = initialTip;
+    this.alreadyAccepted = alreadyAccepted;
   }
 
   public List<CodeReviewCommit> sort(Collection<CodeReviewCommit> incoming)
@@ -50,17 +52,18 @@
 
       rw.resetRetain(canMergeFlag);
       rw.markStart(n);
-      for (RevCommit c : accepted) {
-        // n also tip of directly pushed branch => n remains 'interesting' here
-        if (!c.equals(n)) {
-          rw.markUninteresting(c);
-        }
+      if (initialTip != null) {
+        rw.markUninteresting(initialTip);
       }
 
       CodeReviewCommit c;
       final List<CodeReviewCommit> contents = new ArrayList<>();
       while ((c = rw.next()) != null) {
         if (!c.has(canMergeFlag) || !incoming.contains(c)) {
+          if (isAlreadyMerged(c)) {
+            rw.markUninteresting(c);
+            break;
+          }
           // We cannot merge n as it would bring something we
           // aren't permitted to merge at this time. Drop n.
           //
@@ -86,6 +89,21 @@
     return sorted;
   }
 
+  private boolean isAlreadyMerged(CodeReviewCommit commit) throws IOException {
+    try (CodeReviewRevWalk mirw =
+        CodeReviewCommit.newRevWalk(rw.getObjectReader())) {
+      mirw.reset();
+      mirw.markStart(commit);
+      for (RevCommit accepted : alreadyAccepted) {
+        if (mirw.isMergedInto(mirw.parseCommit(accepted),
+            mirw.parseCommit(commit))) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
   private static <T> T removeOne(final Collection<T> c) {
     final Iterator<T> i = c.iterator();
     final T r = i.next();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
index 4f86927..74310a4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
@@ -288,7 +288,6 @@
   private final GitReferenceUpdated gitRefUpdated;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final PatchSetUtil psUtil;
-  private final GitRepositoryManager repoManager;
   private final ProjectCache projectCache;
   private final String canonicalWebUrl;
   private final CommitValidators.Factory commitValidatorsFactory;
@@ -356,7 +355,6 @@
       PatchSetInfoFactory patchSetInfoFactory,
       PatchSetUtil psUtil,
       ProjectCache projectCache,
-      GitRepositoryManager repoManager,
       TagCache tagCache,
       AccountCache accountCache,
       @Nullable SearchingChangeCacheImpl changeCache,
@@ -395,7 +393,6 @@
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.psUtil = psUtil;
     this.projectCache = projectCache;
-    this.repoManager = repoManager;
     this.canonicalWebUrl = canonicalWebUrl;
     this.tagCache = tagCache;
     this.accountCache = accountCache;
@@ -649,8 +646,11 @@
           logDebug("Reloading project in cache");
           projectCache.evict(project);
           ProjectState ps = projectCache.get(project.getNameKey());
-          repoManager.setProjectDescription(project.getNameKey(), //
-              ps.getProject().getDescription());
+          try {
+            repo.setGitwebDescription(ps.getProject().getDescription());
+          } catch (IOException e) {
+            log.warn("cannot update description of " + project.getName(), e);
+          }
         }
 
         if (!MagicBranch.isMagicBranch(refName)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/UserConfigSections.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/UserConfigSections.java
index a09466d..bbd55f0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/UserConfigSections.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/UserConfigSections.java
@@ -29,6 +29,10 @@
   public static final String KEY_MATCH = "match";
   public static final String KEY_TOKEN = "token";
 
+  /** The table column user preferences. */
+  public static final String CHANGE_TABLE = "changeTable";
+  public static final String CHANGE_TABLE_COLUMN = "column";
+
   /** The edit user preferences. */
   public static final String EDIT = "edit";
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java
index 6334cd2..f7752dc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java
@@ -347,9 +347,11 @@
           case FORCED:
             update.fireGitRefUpdatedEvent(ru);
             return;
+          case LOCK_FAILURE:
+            throw new LockFailureException("Cannot delete " + ru.getName()
+                + " in " + db.getDirectory() + ": " + ru.getResult());
           case FAST_FORWARD:
           case IO_FAILURE:
-          case LOCK_FAILURE:
           case NEW:
           case NOT_ATTEMPTED:
           case NO_CHANGE:
@@ -424,9 +426,11 @@
             revision = rw.parseCommit(ru.getNewObjectId());
             update.fireGitRefUpdatedEvent(ru);
             return revision;
+          case LOCK_FAILURE:
+            throw new LockFailureException("Cannot update " + ru.getName()
+                + " in " + db.getDirectory() + ": " + ru.getResult());
           case FORCED:
           case IO_FAILURE:
-          case LOCK_FAILURE:
           case NOT_ATTEMPTED:
           case NO_CHANGE:
           case REJECTED:
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
index a4656ae..af08b63 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
@@ -33,6 +33,7 @@
 
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
 import java.io.IOException;
@@ -99,8 +100,10 @@
       args.rw.parseBody(toMerge);
       psId = ChangeUtil.nextPatchSetId(
           args.repo, toMerge.change().currentPatchSetId());
+      RevCommit mergeTip = args.mergeTip.getCurrentTip();
+      args.rw.parseBody(mergeTip);
       String cherryPickCmtMsg =
-          args.mergeUtil.createCherryPickCommitMessage(toMerge);
+          args.mergeUtil.createCommitMessageOnSubmit(toMerge, mergeTip);
 
       PersonIdent committer = args.caller.newCommitterIdent(
           ctx.getWhen(), args.serverIdent.getTimeZone());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java
index c93cf6d..135db95 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.server.git.strategy.CommitMergeStatus.SKIPPED_IDENTICAL_TREE;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -39,6 +40,7 @@
 
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
 import java.io.IOException;
@@ -60,7 +62,7 @@
   @Override
   public List<SubmitStrategyOp> buildOps(
       Collection<CodeReviewCommit> toMerge) throws IntegrationException {
-    List<CodeReviewCommit> sorted = sort(toMerge);
+    List<CodeReviewCommit> sorted = sort(toMerge, args.mergeTip.getCurrentTip());
     List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
     boolean first = true;
 
@@ -135,10 +137,10 @@
         args.rw.parseBody(toMerge);
         newPatchSetId = ChangeUtil.nextPatchSetId(
             args.repo, toMerge.change().currentPatchSetId());
-        // TODO(tandrii): add extension point to customize this commit message.
+        RevCommit mergeTip = args.mergeTip.getCurrentTip();
+        args.rw.parseBody(mergeTip);
         String cherryPickCmtMsg =
-            args.mergeUtil.createCherryPickCommitMessage(toMerge);
-
+            args.mergeUtil.createCommitMessageOnSubmit(toMerge, mergeTip);
         PersonIdent committer = args.caller.newCommitterIdent(ctx.getWhen(),
             args.serverIdent.getTimeZone());
         try {
@@ -160,8 +162,6 @@
         // Stale read of patch set is ok; see comments in RebaseChangeOp.
         PatchSet origPs = args.psUtil.get(ctx.getDb(),
             toMerge.getControl().getNotes(), toMerge.getPatchsetId());
-        // TODO(tandrii): add extension point to customize commit message while
-        // rebasing.
         rebaseOp = args.rebaseFactory.create(
               toMerge.getControl(), origPs, args.mergeTip.getCurrentTip().name())
             .setFireRevisionCreated(false)
@@ -169,6 +169,9 @@
             // later anyway.
             .setCopyApprovals(false)
             .setValidatePolicy(CommitValidators.Policy.NONE)
+            // RebaseAlways should set always modify commit message like
+            // Cherry-Pick strategy.
+            .setDetailedCommitMessage(rebaseAlways)
             // Do not post message after inserting new patchset because there
             // will be one about change being merged already.
             .setPostMessage(false);
@@ -256,8 +259,10 @@
             args.inserter, args.destBranch, mergeTip.getCurrentTip(), toMerge);
         mergeTip.moveTipTo(amendGitlink(newTip), toMerge);
       }
+      RevCommit initialTip = mergeTip.getInitialTip();
       args.mergeUtil.markCleanMerges(args.rw, args.canMergeFlag,
-          mergeTip.getCurrentTip(), args.alreadyAccepted);
+          mergeTip.getCurrentTip(), initialTip == null ?
+              ImmutableSet.<RevCommit>of() : ImmutableSet.of(initialTip));
       acceptMergeTip(mergeTip);
     }
   }
@@ -266,11 +271,11 @@
     args.alreadyAccepted.add(mergeTip.getCurrentTip());
   }
 
-  private List<CodeReviewCommit> sort(Collection<CodeReviewCommit> toSort)
-      throws IntegrationException {
+  private List<CodeReviewCommit> sort(Collection<CodeReviewCommit> toSort,
+      RevCommit initialTip) throws IntegrationException {
     try {
-      return new RebaseSorter(
-          args.rw, args.alreadyAccepted, args.canMergeFlag).sort(toSort);
+      return new RebaseSorter(args.rw, initialTip, args.alreadyAccepted,
+          args.canMergeFlag).sort(toSort);
     } catch (IOException e) {
       throw new IntegrationException("Commit sorting failed", e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
index af1052f..ee2fdc4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
@@ -56,6 +56,7 @@
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -485,8 +486,11 @@
       if (RefNames.REFS_CONFIG.equals(getDest().get())) {
         args.projectCache.evict(getProject());
         ProjectState p = args.projectCache.get(getProject());
-        args.repoManager.setProjectDescription(
-            p.getProject().getNameKey(), p.getProject().getDescription());
+        try (Repository git = args.repoManager.openRepository(getProject())) {
+          git.setGitwebDescription(p.getProject().getDescription());
+        } catch (IOException e) {
+          log.error("cannot update description of " + p.getProject().getName(), e);
+        }
       }
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java
index 85c3d15..a53829e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java
@@ -326,12 +326,6 @@
           continue;
         }
       }
-      if (!isAdmin) {
-        GroupControl c = groupControlFactory.controlFor(group);
-        if (!c.isVisible()) {
-          continue;
-        }
-      }
       if (visibleToAll && !group.isVisibleToAll()) {
         continue;
       }
@@ -339,6 +333,12 @@
           && !groupsToInspect.contains(group.getGroupUUID())) {
         continue;
       }
+      if (!isAdmin) {
+        GroupControl c = groupControlFactory.controlFor(group);
+        if (!c.isVisible()) {
+          continue;
+        }
+      }
       filteredGroups.add(group);
     }
     Collections.sort(filteredGroups, new GroupComparator());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/FieldDef.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/FieldDef.java
index 386092d..92938cb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/FieldDef.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/FieldDef.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Preconditions;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gwtorm.server.OrmException;
@@ -65,14 +66,17 @@
   public static class FillArgs {
     public final TrackingFooters trackingFooters;
     public final boolean allowsDrafts;
+    public final AllUsersName allUsers;
 
     @Inject
     FillArgs(TrackingFooters trackingFooters,
-        @GerritServerConfig Config cfg) {
+        @GerritServerConfig Config cfg,
+        AllUsersName allUsers) {
       this.trackingFooters = trackingFooters;
       this.allowsDrafts = cfg == null
           ? true
           : cfg.getBoolean("change", "allowDrafts", true);
+      this.allUsers = allUsers;
     }
   }
 
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/GerritIndexStatus.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/GerritIndexStatus.java
similarity index 98%
rename from gerrit-index/src/main/java/com/google/gerrit/index/GerritIndexStatus.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/index/GerritIndexStatus.java
index cafd30e..5763185 100644
--- a/gerrit-index/src/main/java/com/google/gerrit/index/GerritIndexStatus.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/GerritIndexStatus.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.index;
+package com.google.gerrit.server.index;
 
 import com.google.common.primitives.Ints;
 import com.google.gerrit.server.config.SitePaths;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/Index.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/Index.java
index 533e57c..84eb3bb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/Index.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/Index.java
@@ -17,8 +17,11 @@
 import com.google.gerrit.server.query.DataSource;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
+import com.google.gwtorm.server.OrmException;
 
 import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
 
 /**
  * Secondary index implementation for arbitrary documents.
@@ -91,6 +94,44 @@
       throws QueryParseException;
 
   /**
+   * Get a single document from the index.
+   *
+   * @param key document key.
+   * @param opts query options. Options that do not make sense in the context of
+   *     a single document, such as start, will be ignored.
+   * @return a single document if present.
+   * @throws IOException
+   */
+  default Optional<V> get(K key, QueryOptions opts) throws IOException {
+    opts = opts.withStart(0).withLimit(2);
+    List<V> results;
+    try {
+      results = getSource(keyPredicate(key), opts).read().toList();
+    } catch (QueryParseException e) {
+      throw new IOException("Unexpected QueryParseException during get()", e);
+    } catch (OrmException e) {
+      throw new IOException(e);
+    }
+    switch (results.size()) {
+      case 0:
+        return Optional.empty();
+      case 1:
+        return Optional.of(results.get(0));
+      default:
+        throw new IOException("Multiple results found in index for key "
+            + key + ": " + results);
+    }
+  }
+
+  /**
+   * Get a predicate that looks up a single document by key.
+   *
+   * @param key document key.
+   * @return a single predicate.
+   */
+  Predicate<V> keyPredicate(K key);
+
+  /**
    * Mark whether this index is up-to-date and ready to serve reads.
    *
    * @param ready whether the index is ready
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/IndexUtils.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexUtils.java
similarity index 84%
rename from gerrit-index/src/main/java/com/google/gerrit/index/IndexUtils.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/index/IndexUtils.java
index f00f5c2..bcc7f7b 100644
--- a/gerrit-index/src/main/java/com/google/gerrit/index/IndexUtils.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexUtils.java
@@ -12,8 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.index;
+package com.google.gerrit.server.index;
 
+import static com.google.gerrit.server.index.account.AccountField.ID;
 import static com.google.gerrit.server.index.change.ChangeField.CHANGE;
 import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
@@ -22,7 +23,6 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.QueryOptions;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
@@ -45,7 +45,14 @@
     }
   }
 
-  public static Set<String> fields(QueryOptions opts) {
+  public static Set<String> accountFields(QueryOptions opts) {
+    Set<String> fs = opts.fields();
+    return fs.contains(ID.getName())
+        ? fs
+        : Sets.union(fs, ImmutableSet.of(ID.getName()));
+  }
+
+  public static Set<String> changeFields(QueryOptions opts) {
     // Ensure we request enough fields to construct a ChangeData. We need both
     // change ID and project, which can either come via the Change field or
     // separate fields.
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/SingleVersionModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/SingleVersionModule.java
similarity index 94%
rename from gerrit-index/src/main/java/com/google/gerrit/index/SingleVersionModule.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/index/SingleVersionModule.java
index d547b06..f996c3f 100644
--- a/gerrit-index/src/main/java/com/google/gerrit/index/SingleVersionModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/SingleVersionModule.java
@@ -12,15 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.index;
+package com.google.gerrit.server.index;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.index.Index;
-import com.google.gerrit.server.index.IndexDefinition;
-import com.google.gerrit.server.index.Schema;
 import com.google.inject.Inject;
 import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndex.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndex.java
index cb7b3ef..406982a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndex.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndex.java
@@ -18,9 +18,16 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.index.Index;
 import com.google.gerrit.server.index.IndexDefinition;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.account.AccountPredicates;
 
 public interface AccountIndex extends Index<Account.Id, AccountState> {
   public interface Factory extends
       IndexDefinition.IndexFactory<Account.Id, AccountState, AccountIndex> {
   }
+
+  @Override
+  default Predicate<AccountState> keyPredicate(Account.Id id) {
+    return AccountPredicates.id(id);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index 22416ac..a95e472 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -199,14 +199,15 @@
     // trust the results. This is not an exact percentage since we bump the same
     // failure counter if a project can't be read, but close enough.
     int nFailed = failedTask.getCount();
-    int nTotal = nFailed + doneTask.getCount();
+    int nDone = doneTask.getCount();
+    int nTotal = nFailed + nDone;
     double pctFailed = ((double) nFailed) / nTotal * 100;
     if (pctFailed > 10) {
       log.error("Failed {}/{} changes ({}%); not marking new index as ready",
           nFailed, nTotal, Math.round(pctFailed));
       ok.set(false);
     }
-    return new Result(sw, ok.get(), doneTask.getCount(), failedTask.getCount());
+    return new Result(sw, ok.get(), nDone, nFailed);
   }
 
   private Callable<Void> reindexProject(final ChangeIndexer indexer,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
index 225b756..1d376d5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -36,13 +36,20 @@
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.OutputFormat;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.index.FieldDef;
 import com.google.gerrit.server.index.FieldType;
 import com.google.gerrit.server.index.SchemaUtil;
+import com.google.gerrit.server.index.change.StalenessChecker.RefState;
+import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.notedb.RobotCommentNotes;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
@@ -945,6 +952,76 @@
     return result;
   }
 
+  /**
+   * All values of all refs that were used in the course of indexing this
+   * document.
+   * <p>
+   * Emitted as UTF-8 encoded strings of the form
+   * {@code project:ref/name:[hex sha]}.
+   */
+  public static final FieldDef<ChangeData, Iterable<byte[]>> REF_STATE =
+      new FieldDef.Repeatable<ChangeData, byte[]>(
+          "ref_state", FieldType.STORED_ONLY, true) {
+        @Override
+        public Iterable<byte[]> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          List<byte[]> result = new ArrayList<>();
+          Project.NameKey project = input.change().getProject();
+
+          input.editRefs().values().forEach(
+              r -> result.add(RefState.of(r).toByteArray(project)));
+          input.starRefs().values().forEach(
+              r -> result.add(RefState.of(r.ref()).toByteArray(args.allUsers)));
+
+          if (PrimaryStorage.of(input.change()) == PrimaryStorage.NOTE_DB) {
+            ChangeNotes notes = input.notes();
+            result.add(RefState.create(notes.getRefName(), notes.getMetaId())
+                .toByteArray(project));
+            notes.getRobotComments(); // Force loading robot comments.
+            RobotCommentNotes robotNotes = notes.getRobotCommentNotes();
+            result.add(
+                RefState.create(robotNotes.getRefName(), robotNotes.getMetaId())
+                    .toByteArray(project));
+            input.draftRefs().values().forEach(
+                r -> result.add(RefState.of(r).toByteArray(args.allUsers)));
+          }
+
+          return result;
+        }
+      };
+
+  /**
+   * All ref wildcard patterns that were used in the course of indexing this
+   * document.
+   * <p>
+   * Emitted as UTF-8 encoded strings of the form {@code project:ref/name/*}.
+   * See {@link RefStatePattern} for the pattern format.
+   */
+  public static final FieldDef<ChangeData, Iterable<byte[]>>
+      REF_STATE_PATTERN = new FieldDef.Repeatable<ChangeData, byte[]>(
+          "ref_state_pattern", FieldType.STORED_ONLY, true) {
+        @Override
+        public Iterable<byte[]> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          Change.Id id = input.getId();
+          Project.NameKey project = input.change().getProject();
+          List<byte[]> result = new ArrayList<>(3);
+          result.add(RefStatePattern.create(
+                  RefNames.REFS_USERS + "*/" + RefNames.EDIT_PREFIX + id + "/*")
+              .toByteArray(project));
+          if (PrimaryStorage.of(input.change()) == PrimaryStorage.NOTE_DB) {
+            result.add(
+                RefStatePattern.create(
+                    RefNames.refsStarredChangesPrefix(id) + "*")
+                .toByteArray(args.allUsers));
+            result.add(RefStatePattern.create(
+                    RefNames.refsDraftCommentsPrefix(id) + "*")
+                .toByteArray(args.allUsers));
+          }
+          return result;
+        }
+      };
+
   public static final Integer NOT_REVIEWED = -1;
 
   private static String getTopic(ChangeData input) throws OrmException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndex.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndex.java
index 9545c0a..c56880f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndex.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndex.java
@@ -17,10 +17,17 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.Index;
 import com.google.gerrit.server.index.IndexDefinition;
+import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.LegacyChangeIdPredicate;
 
 public interface ChangeIndex extends Index<Change.Id, ChangeData> {
   public interface Factory extends
       IndexDefinition.IndexFactory<Change.Id, ChangeData, ChangeIndex> {
   }
+
+  @Override
+  default Predicate<ChangeData> keyPredicate(Change.Id id) {
+    return new LegacyChangeIdPredicate(id);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index 5ef548c..f256707 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.index.change;
 
 import static com.google.gerrit.server.extensions.events.EventUtil.logEventListenerError;
+import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
 
 import com.google.common.base.Function;
 import com.google.common.util.concurrent.Atomics;
@@ -28,7 +29,9 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.Index;
+import com.google.gerrit.server.index.IndexExecutor;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -43,6 +46,7 @@
 import com.google.inject.assistedinject.AssistedInject;
 import com.google.inject.util.Providers;
 
+import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -102,16 +106,23 @@
   private final ChangeNotes.Factory changeNotesFactory;
   private final ChangeData.Factory changeDataFactory;
   private final ThreadLocalRequestContext context;
+  private final ListeningExecutorService batchExecutor;
   private final ListeningExecutorService executor;
   private final DynamicSet<ChangeIndexedListener> indexedListeners;
+  private final StalenessChecker stalenessChecker;
+  private final boolean reindexAfterIndexUpdate;
 
   @AssistedInject
-  ChangeIndexer(SchemaFactory<ReviewDb> schemaFactory,
+  ChangeIndexer(
+      @GerritServerConfig Config cfg,
+      SchemaFactory<ReviewDb> schemaFactory,
       NotesMigration notesMigration,
       ChangeNotes.Factory changeNotesFactory,
       ChangeData.Factory changeDataFactory,
       ThreadLocalRequestContext context,
       DynamicSet<ChangeIndexedListener> indexedListeners,
+      StalenessChecker stalenessChecker,
+      @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
       @Assisted ListeningExecutorService executor,
       @Assisted ChangeIndex index) {
     this.executor = executor;
@@ -121,17 +132,23 @@
     this.changeDataFactory = changeDataFactory;
     this.context = context;
     this.indexedListeners = indexedListeners;
+    this.stalenessChecker = stalenessChecker;
+    this.batchExecutor = batchExecutor;
+    this.reindexAfterIndexUpdate = reindexAfterIndexUpdate(cfg);
     this.index = index;
     this.indexes = null;
   }
 
   @AssistedInject
   ChangeIndexer(SchemaFactory<ReviewDb> schemaFactory,
+      @GerritServerConfig Config cfg,
       NotesMigration notesMigration,
       ChangeNotes.Factory changeNotesFactory,
       ChangeData.Factory changeDataFactory,
       ThreadLocalRequestContext context,
       DynamicSet<ChangeIndexedListener> indexedListeners,
+      StalenessChecker stalenessChecker,
+      @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
       @Assisted ListeningExecutorService executor,
       @Assisted ChangeIndexCollection indexes) {
     this.executor = executor;
@@ -141,10 +158,17 @@
     this.changeDataFactory = changeDataFactory;
     this.context = context;
     this.indexedListeners = indexedListeners;
+    this.stalenessChecker = stalenessChecker;
+    this.batchExecutor = batchExecutor;
+    this.reindexAfterIndexUpdate = reindexAfterIndexUpdate(cfg);
     this.index = null;
     this.indexes = indexes;
   }
 
+  private static boolean reindexAfterIndexUpdate(Config cfg) {
+    return cfg.getBoolean("index", null, "testReindexAfterUpdate", true);
+  }
+
   /**
    * Start indexing a change.
    *
@@ -153,9 +177,7 @@
    */
   public CheckedFuture<?, IOException> indexAsync(Project.NameKey project,
       Change.Id id) {
-    return executor != null
-        ? submit(new IndexTask(project, id))
-        : Futures.<Object, IOException> immediateCheckedFuture(null);
+    return submit(new IndexTask(project, id));
   }
 
   /**
@@ -183,6 +205,26 @@
       i.replace(cd);
     }
     fireChangeIndexedEvent(cd.getId().get());
+
+    // Always double-check whether the change might be stale immediately after
+    // interactively indexing it. This fixes up the case where two writers write
+    // to the primary storage in one order, and the corresponding index writes
+    // happen in the opposite order:
+    //  1. Writer A writes to primary storage.
+    //  2. Writer B writes to primary storage.
+    //  3. Writer B updates index.
+    //  4. Writer A updates index.
+    //
+    // Without the extra reindexIfStale step, A has no way of knowing that it's
+    // about to overwrite the index document with stale data. It doesn't work to
+    // have A check for staleness before attempting its index update, because
+    // B's index update might not have happened when it does the check.
+    //
+    // With the extra reindexIfStale step after (3)/(4), we are able to detect
+    // and fix the staleness. It doesn't matter which order the two
+    // reindexIfStale calls actually execute in; we are guaranteed that at least
+    // one of them will execute after the second index write, (4).
+    reindexAfterIndexUpdate(cd);
   }
 
   private void fireChangeIndexedEvent(int id) {
@@ -214,6 +256,8 @@
   public void index(ReviewDb db, Change change)
       throws IOException, OrmException {
     index(newChangeData(db, change));
+    // See comment in #index(ChangeData).
+    reindexAfterIndexUpdate(change.getProject(), change.getId());
   }
 
   /**
@@ -225,7 +269,10 @@
    */
   public void index(ReviewDb db, Project.NameKey project, Change.Id changeId)
       throws IOException, OrmException {
-    index(newChangeData(db, project, changeId));
+    ChangeData cd = newChangeData(db, project, changeId);
+    index(cd);
+    // See comment in #index(ChangeData).
+    reindexAfterIndexUpdate(cd);
   }
 
   /**
@@ -235,9 +282,7 @@
    * @return future for the deleting task.
    */
   public CheckedFuture<?, IOException> deleteAsync(Change.Id id) {
-    return executor != null
-        ? submit(new DeleteTask(id))
-        : Futures.<Object, IOException> immediateCheckedFuture(null);
+    return submit(new DeleteTask(id));
   }
 
   /**
@@ -249,28 +294,68 @@
     new DeleteTask(id).call();
   }
 
+  /**
+   * Asynchronously check if a change is stale, and reindex if it is.
+   * <p>
+   * Always run on the batch executor, even if this indexer instance is
+   * configured to use a different executor.
+   *
+   * @param project the project to which the change belongs.
+   * @param id ID of the change to index.
+   * @return future for reindexing the change; returns true if the change was
+   *     stale.
+   */
+  public CheckedFuture<Boolean, IOException> reindexIfStale(
+      Project.NameKey project, Change.Id id) {
+    return submit(new ReindexIfStaleTask(project, id), batchExecutor);
+  }
+
+  private void reindexAfterIndexUpdate(ChangeData cd) throws IOException {
+    try {
+      reindexAfterIndexUpdate(cd.project(), cd.getId());
+    } catch (OrmException e) {
+      throw new IOException(e);
+    }
+  }
+
+  private void reindexAfterIndexUpdate(Project.NameKey project, Change.Id id) {
+    if (reindexAfterIndexUpdate) {
+      reindexIfStale(project, id);
+    }
+  }
+
   private Collection<ChangeIndex> getWriteIndexes() {
     return indexes != null
         ? indexes.getWriteIndexes()
         : Collections.singleton(index);
   }
 
-  private CheckedFuture<?, IOException> submit(Callable<?> task) {
+  private <T> CheckedFuture<T, IOException> submit(Callable<T> task) {
+    return submit(task, executor);
+  }
+
+  private static <T> CheckedFuture<T, IOException> submit(Callable<T> task,
+      ListeningExecutorService executor) {
     return Futures.makeChecked(
         Futures.nonCancellationPropagating(executor.submit(task)), MAPPER);
   }
 
-  private class IndexTask implements Callable<Void> {
-    private final Project.NameKey project;
-    private final Change.Id id;
+  private abstract class AbstractIndexTask<T> implements Callable<T> {
+    protected final Project.NameKey project;
+    protected final Change.Id id;
 
-    private IndexTask(Project.NameKey project, Change.Id id) {
+    protected AbstractIndexTask(Project.NameKey project, Change.Id id) {
       this.project = project;
       this.id = id;
     }
 
+    protected abstract T callImpl(Provider<ReviewDb> db) throws Exception;
+
     @Override
-    public Void call() throws Exception {
+    public abstract String toString();
+
+    @Override
+    public final T call() throws Exception {
       try {
         final AtomicReference<Provider<ReviewDb>> dbRef =
             Atomics.newReference();
@@ -299,10 +384,7 @@
         };
         RequestContext oldCtx = context.setContext(newCtx);
         try {
-          ChangeData cd = newChangeData(
-              newCtx.getReviewDbProvider().get(), project, id);
-          index(cd);
-          return null;
+          return callImpl(newCtx.getReviewDbProvider());
         } finally  {
           context.setContext(oldCtx);
           Provider<ReviewDb> db = dbRef.get();
@@ -311,17 +393,31 @@
           }
         }
       } catch (Exception e) {
-        log.error(String.format("Failed to index change %d", id.get()), e);
+        log.error("Failed to execute " + this, e);
         throw e;
       }
     }
+  }
+
+  private class IndexTask extends AbstractIndexTask<Void> {
+    private IndexTask(Project.NameKey project, Change.Id id) {
+      super(project, id);
+    }
+
+    @Override
+    public Void callImpl(Provider<ReviewDb> db) throws Exception {
+      ChangeData cd = newChangeData(db.get(), project, id);
+      index(cd);
+      return null;
+    }
 
     @Override
     public String toString() {
-      return "index-change-" + id.get();
+      return "index-change-" + id;
     }
   }
 
+  // Not AbstractIndexTask as it doesn't need ReviewDb.
   private class DeleteTask implements Callable<Void> {
     private final Change.Id id;
 
@@ -343,6 +439,26 @@
     }
   }
 
+  private class ReindexIfStaleTask extends AbstractIndexTask<Boolean> {
+    private ReindexIfStaleTask(Project.NameKey project, Change.Id id) {
+      super(project, id);
+    }
+
+    @Override
+    public Boolean callImpl(Provider<ReviewDb> db) throws Exception {
+      if (!stalenessChecker.isStale(id)) {
+        return false;
+      }
+      index(newChangeData(db.get(), project, id));
+      return true;
+    }
+
+    @Override
+    public String toString() {
+      return "reindex-if-stale-change-" + id;
+    }
+  }
+
   // Avoid auto-rebuilding when reindexing if reading is disabled. This just
   // increases contention on the meta ref from a background indexing thread
   // with little benefit. The next actual write to the entity may still incur a
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 8a793e5..49bccf2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -73,12 +73,18 @@
       .add(ChangeField.LABEL2)
       .build();
 
+  @Deprecated
   static final Schema<ChangeData> V35 =
       schema(V34,
           ChangeField.SUBMIT_RECORD,
           ChangeField.STORED_SUBMIT_RECORD_LENIENT,
           ChangeField.STORED_SUBMIT_RECORD_STRICT);
 
+  static final Schema<ChangeData> V36 =
+      schema(V35,
+          ChangeField.REF_STATE,
+          ChangeField.REF_STATE_PATTERN);
+
   public static final String NAME = "changes";
   public static final ChangeSchemaDefinitions INSTANCE =
       new ChangeSchemaDefinitions();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java
new file mode 100644
index 0000000..ab7eb0e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -0,0 +1,314 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.joining;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.HashMultimap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.SetMultimap;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.IndexConfig;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.regex.Pattern;
+import java.util.stream.StreamSupport;
+
+@Singleton
+public class StalenessChecker {
+  private static final Logger log =
+      LoggerFactory.getLogger(StalenessChecker.class);
+
+  public static final ImmutableSet<String> FIELDS = ImmutableSet.of(
+      ChangeField.CHANGE.getName(),
+      ChangeField.REF_STATE.getName(),
+      ChangeField.REF_STATE_PATTERN.getName());
+
+  private final ChangeIndexCollection indexes;
+  private final GitRepositoryManager repoManager;
+  private final IndexConfig indexConfig;
+  private final Provider<ReviewDb> db;
+
+  @Inject
+  StalenessChecker(
+      ChangeIndexCollection indexes,
+      GitRepositoryManager repoManager,
+      IndexConfig indexConfig,
+      Provider<ReviewDb> db) {
+    this.indexes = indexes;
+    this.repoManager = repoManager;
+    this.indexConfig = indexConfig;
+    this.db = db;
+  }
+
+  public boolean isStale(Change.Id id) throws IOException, OrmException {
+    ChangeIndex i = indexes.getSearchIndex();
+    if (i == null) {
+      return false; // No index; caller couldn't do anything if it is stale.
+    }
+    if (!i.getSchema().hasField(ChangeField.REF_STATE)
+        || !i.getSchema().hasField(ChangeField.REF_STATE_PATTERN)) {
+      return false; // Index version not new enough for this check.
+    }
+
+    Optional<ChangeData> result = i.get(
+        id, IndexedChangeQuery.createOptions(indexConfig, 0, 1, FIELDS));
+    if (!result.isPresent()) {
+      return true; // Not in index, but caller wants it to be.
+    }
+    ChangeData cd = result.get();
+    return isStale(repoManager, id, cd.change(),
+        ChangeNotes.readOneReviewDbChange(db.get(), id),
+        parseStates(cd), parsePatterns(cd));
+  }
+
+  public static boolean isStale(
+      GitRepositoryManager repoManager,
+      Change.Id id,
+      Change indexChange,
+      @Nullable Change reviewDbChange,
+      SetMultimap<Project.NameKey, RefState> states,
+      Multimap<Project.NameKey, RefStatePattern> patterns) {
+    return reviewDbChangeIsStale(indexChange, reviewDbChange)
+        || refsAreStale(repoManager, id, states, patterns);
+  }
+
+  @VisibleForTesting
+  static boolean refsAreStale(GitRepositoryManager repoManager,
+      Change.Id id,
+      SetMultimap<Project.NameKey, RefState> states,
+      Multimap<Project.NameKey, RefStatePattern> patterns) {
+    Set<Project.NameKey> projects =
+        Sets.union(states.keySet(), patterns.keySet());
+
+    for (Project.NameKey p : projects) {
+      if (refsAreStale(repoManager, id, p, states, patterns)) {
+        return true;
+      }
+    }
+
+    return false;
+  }
+
+  @VisibleForTesting
+  static boolean reviewDbChangeIsStale(
+      Change indexChange, @Nullable Change reviewDbChange) {
+    if (reviewDbChange == null) {
+      return false; // Nothing the caller can do.
+    }
+    checkArgument(indexChange.getId().equals(reviewDbChange.getId()),
+        "mismatched change ID: %s != %s",
+        indexChange.getId(), reviewDbChange.getId());
+    if (PrimaryStorage.of(reviewDbChange) != PrimaryStorage.REVIEW_DB) {
+      return false; // Not a ReviewDb change, don't check rowVersion.
+    }
+    return reviewDbChange.getRowVersion() != indexChange.getRowVersion();
+  }
+
+  private SetMultimap<Project.NameKey, RefState> parseStates(ChangeData cd) {
+    return parseStates(cd.getRefStates());
+  }
+
+  public static SetMultimap<Project.NameKey, RefState> parseStates(
+      Iterable<byte[]> states) {
+    RefState.check(states != null, null);
+    SetMultimap<Project.NameKey, RefState> result = HashMultimap.create();
+    for (byte[] b : states) {
+      RefState.check(b != null, null);
+      String s = new String(b, UTF_8);
+      List<String> parts = Splitter.on(':').splitToList(s);
+      RefState.check(
+          parts.size() == 3
+              && !parts.get(0).isEmpty()
+              && !parts.get(1).isEmpty(),
+          s);
+      result.put(
+          new Project.NameKey(parts.get(0)),
+          RefState.create(parts.get(1), parts.get(2)));
+    }
+    return result;
+  }
+
+  private Multimap<Project.NameKey, RefStatePattern> parsePatterns(
+      ChangeData cd) {
+    return parsePatterns(cd.getRefStatePatterns());
+  }
+
+  public static ListMultimap<Project.NameKey, RefStatePattern> parsePatterns(
+      Iterable<byte[]> patterns) {
+    RefStatePattern.check(patterns != null, null);
+    ListMultimap<Project.NameKey, RefStatePattern> result =
+        ArrayListMultimap.create();
+    for (byte[] b : patterns) {
+      RefStatePattern.check(b != null, null);
+      String s = new String(b, UTF_8);
+      List<String> parts = Splitter.on(':').splitToList(s);
+      RefStatePattern.check(parts.size() == 2, s);
+      result.put(
+          new Project.NameKey(parts.get(0)),
+          RefStatePattern.create(parts.get(1)));
+    }
+    return result;
+  }
+
+  private static boolean refsAreStale(GitRepositoryManager repoManager,
+      Change.Id id, Project.NameKey project,
+      SetMultimap<Project.NameKey, RefState> allStates,
+      Multimap<Project.NameKey, RefStatePattern> allPatterns) {
+    try (Repository repo = repoManager.openRepository(project)) {
+      Set<RefState> states = allStates.get(project);
+      for (RefState state : states) {
+        if (!state.match(repo)) {
+          return true;
+        }
+      }
+      for (RefStatePattern pattern : allPatterns.get(project)) {
+        if (!pattern.match(repo, states)) {
+          return true;
+        }
+      }
+      return false;
+    } catch (IOException e) {
+      log.warn(
+          String.format("error checking staleness of %s in %s", id, project),
+          e);
+      return true;
+    }
+  }
+
+  @AutoValue
+  public abstract static class RefState {
+    static RefState create(String ref, String sha) {
+      return new AutoValue_StalenessChecker_RefState(
+          ref, ObjectId.fromString(sha));
+    }
+
+    static RefState create(String ref, @Nullable ObjectId id) {
+      return new AutoValue_StalenessChecker_RefState(
+          ref, firstNonNull(id, ObjectId.zeroId()));
+    }
+
+    static RefState of(Ref ref) {
+      return new AutoValue_StalenessChecker_RefState(
+          ref.getName(), ref.getObjectId());
+    }
+
+    byte[] toByteArray(Project.NameKey project) {
+      byte[] a = (project.toString() + ':' + ref() + ':').getBytes(UTF_8);
+      byte[] b = new byte[a.length + Constants.OBJECT_ID_STRING_LENGTH];
+      System.arraycopy(a, 0, b, 0, a.length);
+      id().copyTo(b, a.length);
+      return b;
+    }
+
+    private static void check(boolean condition, String str) {
+      checkArgument(condition, "invalid RefState: %s", str);
+    }
+
+    abstract String ref();
+    abstract ObjectId id();
+
+    private boolean match(Repository repo) throws IOException {
+      Ref ref = repo.exactRef(ref());
+      ObjectId expected = ref != null ? ref.getObjectId() : ObjectId.zeroId();
+      return id().equals(expected);
+    }
+  }
+
+  /**
+   * Pattern for matching refs.
+   * <p>
+   * Similar to '*' syntax for native Git refspecs, but slightly more powerful:
+   * the pattern may contain arbitrarily many asterisks. There must be at least
+   * one '*' and the first one must immediately follow a '/'.
+   */
+  @AutoValue
+  public abstract static class RefStatePattern {
+    static RefStatePattern create(String pattern) {
+      int star = pattern.indexOf('*');
+      check(star > 0 && pattern.charAt(star - 1) == '/', pattern);
+      String prefix = pattern.substring(0, star);
+      check(Repository.isValidRefName(pattern.replace('*', 'x')), pattern);
+
+      // Quote everything except the '*'s, which become ".*".
+      String regex =
+          StreamSupport.stream(Splitter.on('*').split(pattern).spliterator(), false)
+              .map(Pattern::quote)
+              .collect(joining(".*", "^", "$"));
+      return new AutoValue_StalenessChecker_RefStatePattern(
+          pattern, prefix, Pattern.compile(regex));
+    }
+
+    byte[] toByteArray(Project.NameKey project) {
+      return (project.toString() + ':' + pattern()).getBytes(UTF_8);
+    }
+
+    private static void check(boolean condition, String str) {
+      checkArgument(condition, "invalid RefStatePattern: %s", str);
+    }
+
+    abstract String pattern();
+    abstract String prefix();
+    abstract Pattern regex();
+
+    boolean match(String refName) {
+      return regex().matcher(refName).find();
+    }
+
+    private boolean match(Repository repo, Set<RefState> expected)
+        throws IOException {
+      for (Ref r : repo.getRefDatabase().getRefs(prefix()).values()) {
+        if (!match(r.getName())) {
+          continue;
+        }
+        if (!expected.contains(RefState.of(r))) {
+          return false;
+        }
+      }
+      return true;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java
index 8a132cd..ca8d101 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java
@@ -28,6 +28,7 @@
 import org.eclipse.jgit.revwalk.FooterKey;
 import org.eclipse.jgit.revwalk.FooterLine;
 
+import java.time.format.DateTimeFormatter;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
@@ -35,6 +36,9 @@
 import java.util.regex.Pattern;
 
 public class MailUtil {
+  public static DateTimeFormatter rfcDateformatter =
+      DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss ZZZ");
+
   public static MailRecipients getRecipientsFromFooters(
       ReviewDb db, AccountResolver accountResolver, boolean draftPatchSet,
       List<FooterLine> footerLines) throws OrmException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MetadataName.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MetadataName.java
new file mode 100644
index 0000000..3db55c0
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MetadataName.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail;
+
+public final class MetadataName {
+  public static final String CHANGE_ID = "Gerrit-Change-Id";
+  public static final String PATCH_SET = "Gerrit-PatchSet";
+  public static final String MESSAGE_TYPE = "Gerrit-MessageType";
+  public static final String TIMESTAMP = "Gerrit-Comment-Date";
+
+  public static String toHeader(String metadataName) {
+    return "X-" + metadataName;
+  }
+
+  public static String toHeaderWithDelimiter(String metadataName) {
+    return toHeader(metadataName) + ": ";
+  }
+
+  public static String toFooterWithDelimiter(String metadataName) {
+    return metadataName + ": ";
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailMetadata.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailMetadata.java
new file mode 100644
index 0000000..c353e54
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailMetadata.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.receive;
+
+import com.google.common.base.MoreObjects;
+
+import java.sql.Timestamp;
+
+/** MailMetadata represents metadata parsed from inbound email. */
+public class MailMetadata {
+  public String changeId;
+  public Integer patchSet;
+  public String author; // Author of the email
+  public Timestamp timestamp;
+  public String messageType; // we expect comment here
+
+
+  public boolean hasRequiredFields() {
+    return changeId != null && patchSet != null && author != null &&
+        timestamp != null && messageType != null;
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("Change-Id", changeId)
+        .add("Patch-Set", patchSet)
+        .add("Author", author)
+        .add("Timestamp", timestamp)
+        .add("Message-Type", messageType)
+        .toString();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MetadataParser.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MetadataParser.java
new file mode 100644
index 0000000..1a3b14d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MetadataParser.java
@@ -0,0 +1,109 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.receive;
+
+import static com.google.gerrit.server.mail.MetadataName.toFooterWithDelimiter;
+import static com.google.gerrit.server.mail.MetadataName.toHeaderWithDelimiter;
+
+import com.google.common.base.Strings;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.server.mail.MailUtil;
+import com.google.gerrit.server.mail.MetadataName;
+
+import java.sql.Timestamp;
+import java.time.Instant;
+
+/** Parse metadata from inbound email */
+public class MetadataParser {
+  public static MailMetadata parse(MailMessage m) {
+    MailMetadata metadata = new MailMetadata();
+    // Find author
+    metadata.author = m.from().getEmail();
+
+    // Check email headers for X-Gerrit-<Name>
+    for (String header : m.additionalHeaders()) {
+      if (header.startsWith(toHeaderWithDelimiter(MetadataName.CHANGE_ID))) {
+        metadata.changeId = header
+            .substring(toHeaderWithDelimiter(MetadataName.CHANGE_ID).length());
+      } else if (header.startsWith(
+          toHeaderWithDelimiter(MetadataName.PATCH_SET))) {
+        String ps = header.substring(
+            toHeaderWithDelimiter(MetadataName.PATCH_SET).length());
+        metadata.patchSet = Ints.tryParse(ps);
+      } else if (header.startsWith(
+          toHeaderWithDelimiter(MetadataName.TIMESTAMP))) {
+        String ts = header.substring(
+            toHeaderWithDelimiter(MetadataName.TIMESTAMP).length());
+        metadata.timestamp = Timestamp.from(
+            MailUtil.rfcDateformatter.parse(ts, Instant::from));
+      } else if (header.startsWith(
+          toHeaderWithDelimiter(MetadataName.MESSAGE_TYPE))) {
+        metadata.messageType = header.substring(
+            toHeaderWithDelimiter(MetadataName.MESSAGE_TYPE).length());
+      }
+    }
+    if (metadata.hasRequiredFields()) {
+      return metadata;
+    }
+
+    // If the required fields were not yet found, continue to parse the text
+    if (!Strings.isNullOrEmpty(m.textContent())) {
+      String[] lines = m.textContent().split("\n");
+      extractFooters(lines, metadata);
+      if (metadata.hasRequiredFields()) {
+        return metadata;
+      }
+    }
+
+    // If the required fields were not yet found, continue to parse the HTML
+    // HTML footer are contained inside a <p> tag
+    if (!Strings.isNullOrEmpty(m.htmlContent())) {
+      String[] lines = m.htmlContent().split("</p>");
+      extractFooters(lines, metadata);
+      if (metadata.hasRequiredFields()) {
+        return metadata;
+      }
+    }
+
+    return metadata;
+  }
+
+  private static void extractFooters(String[] lines, MailMetadata metadata) {
+    for (String line : lines) {
+      if (metadata.changeId == null && line.contains(MetadataName.CHANGE_ID)) {
+        metadata.changeId =
+            extractFooter(toFooterWithDelimiter(MetadataName.CHANGE_ID), line);
+      } else if (metadata.patchSet == null &&
+          line.contains(MetadataName.PATCH_SET)) {
+        metadata.patchSet = Ints.tryParse(
+            extractFooter(toFooterWithDelimiter(MetadataName.PATCH_SET), line));
+      } else if (metadata.timestamp == null &&
+          line.contains(MetadataName.TIMESTAMP)) {
+        String ts =
+            extractFooter(toFooterWithDelimiter(MetadataName.TIMESTAMP), line);
+        metadata.timestamp = Timestamp.from(
+            MailUtil.rfcDateformatter.parse(ts, Instant::from));
+      } else if (metadata.messageType == null &&
+          line.contains(MetadataName.MESSAGE_TYPE)) {
+        metadata.messageType = extractFooter(
+            toFooterWithDelimiter(MetadataName.MESSAGE_TYPE), line);
+      }
+    }
+  }
+
+  private static String extractFooter(String key, String line) {
+    return line.substring(line.indexOf(key) + key.length(), line.length());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 7053e84..83ffab6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2010 The Android Open Source Project
+// Copyright (C) 2016 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -156,12 +156,15 @@
       }
     }
 
-    if (patchSet != null && patchSetInfo == null) {
-      try {
-        patchSetInfo = args.patchSetInfoFactory.get(
-            args.db.get(), changeData.notes(), patchSet.getId());
-      } catch (PatchSetInfoNotAvailableException | OrmException err) {
-        patchSetInfo = null;
+    if (patchSet != null) {
+      setHeader("X-Gerrit-PatchSet", patchSet.getPatchSetId() + "");
+      if (patchSetInfo == null) {
+        try {
+          patchSetInfo = args.patchSetInfoFactory.get(
+              args.db.get(), changeData.notes(), patchSet.getId());
+        } catch (PatchSetInfoNotAvailableException | OrmException err) {
+          patchSetInfo = null;
+        }
       }
     }
     authors = getAuthors();
@@ -464,12 +467,20 @@
     patchSetData.put("refName", patchSet.getRefName());
     soyContext.put("patchSet", patchSetData);
 
-    soyContext.put("reviewerEmails",
-        getEmailsByState(ReviewerStateInternal.REVIEWER));
-    soyContext.put("ccEmails",
-        getEmailsByState(ReviewerStateInternal.CC));
-
     // TODO(wyatta): patchSetInfo
+
+    footers.add("Gerrit-MessageType: " + messageClass);
+    footers.add("Gerrit-Change-Id: " + change.getKey().get());
+    footers.add("Gerrit-Change-Number: " +
+        Integer.toString(change.getChangeId()));
+    footers.add("Gerrit-PatchSet: " + patchSet.getPatchSetId());
+    footers.add("Gerrit-Owner: " + getNameEmailFor(change.getOwner()));
+    for (String reviewer : getEmailsByState(ReviewerStateInternal.REVIEWER)) {
+      footers.add("Gerrit-Reviewer: " + reviewer);
+    }
+    for (String reviewer : getEmailsByState(ReviewerStateInternal.CC)) {
+      footers.add("Gerrit-CC: " + reviewer);
+    }
   }
 
   private Set<String> getEmailsByState(ReviewerStateInternal state) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentFormatter.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentFormatter.java
new file mode 100644
index 0000000..722fe1f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentFormatter.java
@@ -0,0 +1,190 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import static com.google.common.base.Strings.isNullOrEmpty;
+
+import com.google.gerrit.common.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class CommentFormatter {
+  public enum BlockType {
+    LIST,
+    PARAGRAPH,
+    PRE_FORMATTED,
+    QUOTE
+  }
+
+  public static class Block {
+    public BlockType type;
+    public String text;
+    public List<String> items; // For the items of list blocks.
+    public List<Block> quotedBlocks; // For the contents of quote blocks.
+  }
+
+  /**
+   * Take a string of comment text that was written using the wiki-Like format
+   * and emit a list of blocks that can be rendered to block-level HTML. This
+   * method does not escape HTML.
+   *
+   * Adapted from the {@code wikify} method found in:
+   *   com.google.gwtexpui.safehtml.client.SafeHtml
+   *
+   * @param source The raw, unescaped comment in the Gerrit wiki-like format.
+   * @return List of block objects, each with unescaped comment content.
+   */
+  public static List<Block> parse(@Nullable String source) {
+    if (isNullOrEmpty(source)) {
+      return Collections.emptyList();
+    }
+
+    List<Block> result = new ArrayList<>();
+    for (String p : source.split("\n\n")) {
+      if (isQuote(p)) {
+        result.add(makeQuote(p));
+      } else if (isPreFormat(p)) {
+        result.add(makePre(p));
+      } else if (isList(p)) {
+        makeList(p, result);
+      } else if (!p.isEmpty()) {
+        result.add(makeParagraph(p));
+      }
+    }
+    return result;
+  }
+
+  /**
+   * Take a block of comment text that contains a list and potentially
+   * paragraphs (but does not contain blank lines), generate appropriate block
+   * elements and append them to the output list.
+   *
+   * In simple cases, this will generate a single list block. For example, on
+   * the following input.
+   *
+   *    * Item one.
+   *    * Item two.
+   *    * item three.
+   *
+   * However, if the list is adjacent to a paragraph, it will need to also
+   * generate that paragraph. Consider the following input.
+   *
+   *    A bit of text describing the context of the list:
+   *    * List item one.
+   *    * List item two.
+   *    * Et cetera.
+   *
+   * In this case, {@code makeList} generates a paragraph block object
+   * containing the non-bullet-prefixed text, followed by a list block.
+   *
+   * Adapted from the {@code wikifyList} method found in:
+   *   com.google.gwtexpui.safehtml.client.SafeHtml
+   *
+   * @param p The block containing the list (as well as potential paragraphs).
+   * @param out The list of blocks to append to.
+   */
+  private static void makeList(String p, List<Block> out) {
+    Block block = null;
+    StringBuilder textBuilder = null;
+    boolean inList = false;
+    boolean inParagraph = false;
+
+    for (String line : p.split("\n")) {
+      if (line.startsWith("-") || line.startsWith("*")) {
+        // The next line looks like a list item. If not building a list already,
+        // then create one. Remove the list item marker (* or -) from the line.
+        if (!inList) {
+          if (inParagraph) {
+            // Add the finished paragraph block to the result.
+            inParagraph = false;
+            block.text = textBuilder.toString();
+            out.add(block);
+          }
+
+          inList = true;
+          block = new Block();
+          block.type = BlockType.LIST;
+          block.items = new ArrayList<>();
+        }
+        line = line.substring(1).trim();
+
+      } else if (!inList) {
+        // Otherwise, if a list has not yet been started, but the next line does
+        // not look like a list item, then add the line to a paragraph block. If
+        // a paragraph block has not yet been started, then create one.
+        if (!inParagraph) {
+          inParagraph = true;
+          block = new Block();
+          block.type = BlockType.PARAGRAPH;
+          textBuilder = new StringBuilder();
+        } else {
+          textBuilder.append(" ");
+        }
+        textBuilder.append(line);
+        continue;
+      }
+
+      block.items.add(line);
+    }
+
+    if (block != null) {
+      out.add(block);
+    }
+  }
+
+  private static Block makeQuote(String p) {
+    String quote = p.replaceAll("\n\\s?>\\s?", "\n");
+    if (quote.startsWith("> ")) {
+      quote = quote.substring(2);
+    } else if (quote.startsWith(" > ")) {
+      quote = quote.substring(3);
+    }
+
+    Block block = new Block();
+    block.type = BlockType.QUOTE;
+    block.quotedBlocks = CommentFormatter.parse(quote);
+    return block;
+  }
+
+  private static Block makePre(String p) {
+    Block block = new Block();
+    block.type = BlockType.PRE_FORMATTED;
+    block.text = p;
+    return block;
+  }
+
+  private static Block makeParagraph(String p) {
+    Block block = new Block();
+    block.type = BlockType.PARAGRAPH;
+    block.text = p;
+    return block;
+  }
+
+  private static boolean isQuote(String p) {
+    return p.startsWith("> ") || p.startsWith(" > ");
+  }
+
+  private static boolean isPreFormat(String p) {
+    return p.startsWith(" ") || p.startsWith("\t")
+        || p.contains("\n ") || p.contains("\n\t");
+  }
+
+  private static boolean isList(String p) {
+    return p.startsWith("- ") || p.startsWith("* ")
+        || p.contains("\n- ") || p.contains("\n* ");
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java
index 77086f7..0cc4152 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.mail.MailUtil;
 import com.google.gerrit.server.patch.PatchFile;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
@@ -40,6 +41,8 @@
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
@@ -49,6 +52,7 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.stream.Collectors;
 
 /** Send comments, after the author of them hit used Publish Comments in the UI.
  */
@@ -133,6 +137,10 @@
       bccStarredBy();
       includeWatchers(NotifyType.ALL_COMMENTS);
     }
+
+    // Add header that enables identifying comments on parsed email.
+    // Grouping is currently done by timestamp.
+    setHeader("X-Gerrit-Comment-Date", timestamp);
   }
 
   @Override
@@ -175,68 +183,69 @@
    */
   @Deprecated
   public String getInlineComments(int lines) {
-    StringBuilder cmts = new StringBuilder();
-    for (FileCommentGroup group : getGroupedInlineComments()) {
-      String link = group.getLink();
-      if (link != null) {
-        cmts.append(link).append('\n');
+    try (Repository repo = getRepository()) {
+      StringBuilder cmts = new StringBuilder();
+      for (FileCommentGroup group : getGroupedInlineComments(repo)) {
+        String link = group.getLink();
+        if (link != null) {
+          cmts.append(link).append('\n');
+        }
+        cmts.append(group.getTitle()).append(":\n\n");
+        for (Comment c : group.comments) {
+          appendComment(cmts, lines, group.fileData, c);
+        }
+        cmts.append("\n\n");
       }
-      cmts.append(group.getTitle()).append(":\n\n");
-      for (Comment c : group.comments) {
-        appendComment(cmts, lines, group.fileData, c);
-      }
-      cmts.append("\n\n");
+      return cmts.toString();
     }
-    return cmts.toString();
   }
 
   /**
    * @return a list of FileCommentGroup objects representing the inline comments
    * grouped by the file.
    */
-  private List<CommentSender.FileCommentGroup> getGroupedInlineComments() {
+  private List<CommentSender.FileCommentGroup> getGroupedInlineComments(
+      Repository repo) {
     List<CommentSender.FileCommentGroup> groups = new ArrayList<>();
-    try (Repository repo = getRepository()) {
-      // Get the patch list:
-      PatchList patchList = null;
-      if (repo != null) {
-        try {
-          patchList = getPatchList();
-        } catch (PatchListNotAvailableException e) {
-          log.error("Failed to get patch list", e);
+    // Get the patch list:
+    PatchList patchList = null;
+    if (repo != null) {
+      try {
+        patchList = getPatchList();
+      } catch (PatchListNotAvailableException e) {
+        log.error("Failed to get patch list", e);
+      }
+    }
+
+    // Loop over the comments and collect them into groups based on the file
+    // location of the comment.
+    FileCommentGroup currentGroup = null;
+    for (Comment c : inlineComments) {
+      // If it's a new group:
+      if (currentGroup == null
+          || !c.key.filename.equals(currentGroup.filename)
+          || c.key.patchSetId != currentGroup.patchSetId) {
+        currentGroup = new FileCommentGroup();
+        currentGroup.filename = c.key.filename;
+        currentGroup.patchSetId = c.key.patchSetId;
+        groups.add(currentGroup);
+        if (patchList != null) {
+          try {
+            currentGroup.fileData =
+                new PatchFile(repo, patchList, c.key.filename);
+          } catch (IOException e) {
+            log.warn(String.format(
+                "Cannot load %s from %s in %s",
+                c.key.filename,
+                patchList.getNewId().name(),
+                projectState.getProject().getName()), e);
+            currentGroup.fileData = null;
+          }
         }
       }
 
-      // Loop over the comments and collect them into groups based on the file
-      // location of the comment.
-      FileCommentGroup currentGroup = null;
-      for (Comment c : inlineComments) {
-        // If it's a new group:
-        if (currentGroup == null
-            || !c.key.filename.equals(currentGroup.filename)
-            || c.key.patchSetId != currentGroup.patchSetId) {
-          currentGroup = new FileCommentGroup();
-          currentGroup.filename = c.key.filename;
-          currentGroup.patchSetId = c.key.patchSetId;
-          groups.add(currentGroup);
-          if (patchList != null) {
-            try {
-              currentGroup.fileData =
-                  new PatchFile(repo, patchList, c.key.filename);
-            } catch (IOException e) {
-              log.warn(String.format(
-                  "Cannot load %s from %s in %s",
-                  c.key.filename,
-                  patchList.getNewId().name(),
-                  projectState.getProject().getName()), e);
-              currentGroup.fileData = null;
-            }
-          }
-        }
-
-        if (currentGroup.fileData != null) {
-          currentGroup.comments.add(c);
-        }
+      if (currentGroup.fileData != null) {
+        currentGroup.comments.add(c);
       }
     }
 
@@ -424,6 +433,10 @@
    */
   private List<String> getLinesOfComment(Comment comment, PatchFile fileData) {
     List<String> lines = new ArrayList<>();
+    if (comment.lineNbr == 0) {
+      // file level comment has no line
+      return lines;
+    }
     if (comment.range == null) {
       lines.add(getLine(fileData, comment.side, comment.lineNbr));
     } else {
@@ -452,10 +465,12 @@
    * @return grouped inline comment data mapped to data structures that are
    * suitable for passing into Soy.
    */
-  private List<Map<String, Object>> getCommentGroupsTemplateData() {
+  private List<Map<String, Object>> getCommentGroupsTemplateData(
+      Repository repo) {
     List<Map<String, Object>> commentGroups = new ArrayList<>();
 
-    for (CommentSender.FileCommentGroup group : getGroupedInlineComments()) {
+    for (
+        CommentSender.FileCommentGroup group : getGroupedInlineComments(repo)) {
       Map<String, Object> groupData = new HashMap<>();
       groupData.put("link", group.getLink());
       groupData.put("title", group.getTitle());
@@ -466,6 +481,9 @@
         Map<String, Object> commentData = new HashMap<>();
         commentData.put("lines", getLinesOfComment(comment, group.fileData));
         commentData.put("message", comment.message.trim());
+        List<CommentFormatter.Block> blocks =
+            CommentFormatter.parse(comment.message);
+        commentData.put("messageBlocks", commentBlocksToSoyData(blocks));
 
         // Set the prefix.
         String prefix = getCommentLinePrefix(comment);
@@ -503,11 +521,14 @@
           commentData.put("isRobotComment", false);
         }
 
-        // Set parent comment info.
-        Optional<Comment> parent = getParent(comment);
-        if (parent.isPresent()) {
-          commentData.put("parentMessage",
-              getShortenedCommentMessage(parent.get()));
+        // If the comment has a quote, don't bother loading the parent message.
+        if (!hasQuote(blocks)) {
+          // Set parent comment info.
+          Optional<Comment> parent = getParent(comment);
+          if (parent.isPresent()) {
+            commentData.put("parentMessage",
+                getShortenedCommentMessage(parent.get()));
+          }
         }
 
         commentsList.add(commentData);
@@ -519,6 +540,43 @@
     return commentGroups;
   }
 
+  private List<Map<String, Object>> commentBlocksToSoyData(
+      List<CommentFormatter.Block> blocks) {
+    return blocks.stream()
+        .map(b -> {
+          Map<String, Object> map = new HashMap<>();
+          switch (b.type) {
+            case PARAGRAPH:
+              map.put("type", "paragraph");
+              map.put("text", b.text);
+              break;
+            case PRE_FORMATTED:
+              map.put("type", "pre");
+              map.put("text", b.text);
+              break;
+            case QUOTE:
+              map.put("type", "quote");
+              map.put("quotedBlocks", commentBlocksToSoyData(b.quotedBlocks));
+              break;
+            case LIST:
+              map.put("type", "list");
+              map.put("items", b.items);
+              break;
+          }
+          return map;
+        })
+        .collect(Collectors.toList());
+  }
+
+  private boolean hasQuote(List<CommentFormatter.Block> blocks) {
+    for (CommentFormatter.Block block : blocks) {
+      if (block.type == CommentFormatter.BlockType.QUOTE) {
+        return true;
+      }
+    }
+    return false;
+  }
+
   private Repository getRepository() {
     try {
       return args.server.openRepository(projectState.getProject().getNameKey());
@@ -530,7 +588,19 @@
   @Override
   protected void setupSoyContext() {
     super.setupSoyContext();
-    soyContext.put("commentFiles", getCommentGroupsTemplateData());
+    boolean hasComments = false;
+    try (Repository repo = getRepository()) {
+      List<Map<String, Object>> files = getCommentGroupsTemplateData(repo);
+      soyContext.put("commentFiles", files);
+      hasComments = !files.isEmpty();
+    }
+
+    soyContext.put("commentTimestamp", getCommentTimestamp());
+    soyContext.put("coverLetterBlocks",
+        commentBlocksToSoyData(CommentFormatter.parse(getCoverLetter())));
+
+    footers.add("Gerrit-Comment-Date: " + getCommentTimestamp());
+    footers.add("Gerrit-HasComments: " + (hasComments ? "Yes" : "No"));
   }
 
   private String getLine(PatchFile fileInfo, short side, int lineNbr) {
@@ -553,6 +623,12 @@
     }
   }
 
+  private String getCommentTimestamp() {
+    // Grouping is currently done by timestamp.
+    return MailUtil.rfcDateformatter.format(
+        ZonedDateTime.ofInstant(timestamp.toInstant(), ZoneId.of("UTC")));
+  }
+
   @Override
   protected boolean supportsHtml() {
     return true;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NotificationEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NotificationEmail.java
index baf0d93..0ded9d8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NotificationEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NotificationEmail.java
@@ -123,5 +123,8 @@
     Map<String, String> branchData = new HashMap<>();
     branchData.put("shortName", branch.getShortName());
     soyContext.put("branch", branchData);
+
+    footers.add("Gerrit-Project: " + branch.getParentKey().get());
+    footers.add("Gerrit-Branch: " + branch.getShortName());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index 1051b9d..b0ba978 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -48,12 +48,14 @@
 import java.net.URL;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
@@ -74,6 +76,7 @@
   protected VelocityContext velocityContext;
   protected Map<String, Object> soyContext;
   protected Map<String, Object> soyContextEmailData;
+  protected List<String> footers;
   protected final EmailArguments args;
   protected Account.Id fromId;
   protected NotifyHandling notify = NotifyHandling.ALL;
@@ -462,8 +465,10 @@
 
   protected void setupSoyContext() {
     soyContext = new HashMap<>();
+    footers = new ArrayList<>();
 
     soyContext.put("messageClass", messageClass);
+    soyContext.put("footers", footers);
 
     soyContextEmailData = new HashMap<>();
     soyContextEmailData.put("settingsUrl", getSettingsUrl());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
index d0b0c54..a3ea381 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -372,6 +372,10 @@
     return change;
   }
 
+  public ObjectId getMetaId() {
+    return state.metaId();
+  }
+
   public ImmutableSortedMap<PatchSet.Id, PatchSet> getPatchSets() {
     if (patchSets == null) {
       ImmutableSortedMap.Builder<PatchSet.Id, PatchSet> b =
@@ -510,7 +514,7 @@
     return draftCommentNotes;
   }
 
-  RobotCommentNotes getRobotCommentNotes() {
+  public RobotCommentNotes getRobotCommentNotes() {
     return robotCommentNotes;
   }
 
@@ -532,7 +536,7 @@
   }
 
   @Override
-  protected String getRefName() {
+  public String getRefName() {
     return changeMetaRef(getChangeId());
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
index 80f9eaf..dcc4213 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
@@ -68,7 +68,7 @@
 
     private final char code;
 
-    private PrimaryStorage(char code) {
+    PrimaryStorage(char code) {
       this.code = code;
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index 1e5cb1e..cb8f159 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.notedb;
 
+import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
@@ -551,11 +552,12 @@
       Account.Id accountId = u.getAccountId();
       if (!expectedState.areDraftsUpToDate(
           allUsersRepo.cmds.getRepoRefCache(), accountId)) {
+        ObjectId expectedDraftId = firstNonNull(
+            expectedState.getDraftIds().get(accountId), ObjectId.zeroId());
         throw new OrmConcurrencyException(String.format(
             "cannot apply NoteDb updates for change %s;"
             + " draft ref for account %s does not match %s",
-            u.getId(), accountId,
-            expectedState.getChangeMetaId().name()));
+            u.getId(), accountId, expectedDraftId.name()));
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
index f60fd2d..ae280f4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
@@ -17,6 +17,7 @@
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Multimap;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -43,6 +44,7 @@
 
   private ImmutableListMultimap<RevId, RobotComment> comments;
   private RevisionNoteMap<RobotCommentsRevisionNote> revisionNoteMap;
+  private ObjectId metaId;
 
   @AssistedInject
   RobotCommentNotes(
@@ -70,20 +72,26 @@
   }
 
   @Override
-  protected String getRefName() {
+  public String getRefName() {
     return RefNames.robotCommentsRef(getChangeId());
   }
 
+  @Nullable
+  public ObjectId getMetaId() {
+    return metaId;
+  }
+
   @Override
   protected void onLoad(LoadHandle handle)
       throws IOException, ConfigInvalidException {
-    ObjectId rev = handle.id();
-    if (rev == null) {
+    metaId = handle.id();
+    if (metaId == null) {
       loadDefaults();
       return;
     }
+    metaId = metaId.copy();
 
-    RevCommit tipCommit = handle.walk().parseCommit(rev);
+    RevCommit tipCommit = handle.walk().parseCommit(metaId);
     ObjectReader reader = handle.walk().getObjectReader();
     revisionNoteMap = RevisionNoteMap.parseRobotComments(args.noteUtil, reader,
         NoteMap.read(reader, tipCommit));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/TestChangeRebuilderWrapper.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/TestChangeRebuilderWrapper.java
index 1c585fc..653cd9d0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/TestChangeRebuilderWrapper.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/TestChangeRebuilderWrapper.java
@@ -26,8 +26,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
 import java.io.IOException;
 import java.util.concurrent.atomic.AtomicBoolean;
 
@@ -57,8 +55,7 @@
 
   @Override
   public Result rebuild(ReviewDb db, Change.Id changeId)
-      throws NoSuchChangeException, IOException, OrmException,
-      ConfigInvalidException {
+      throws NoSuchChangeException, IOException, OrmException {
     if (failNextUpdate.getAndSet(false)) {
       throw new IOException("Update failed");
     }
@@ -72,7 +69,7 @@
   @Override
   public Result rebuild(NoteDbUpdateManager manager,
       ChangeBundle bundle) throws NoSuchChangeException, IOException,
-      OrmException, ConfigInvalidException {
+      OrmException {
     // stealNextUpdate doesn't really apply in this case because the IOException
     // would normally come from the manager.execute() method, which isn't called
     // here.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java
index a63fbad..24d62c0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java
@@ -25,8 +25,6 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
 import java.io.IOException;
 import java.util.concurrent.Callable;
 
@@ -59,12 +57,11 @@
   }
 
   public abstract Result rebuild(ReviewDb db, Change.Id changeId)
-      throws NoSuchChangeException, IOException, OrmException,
-      ConfigInvalidException;
+      throws NoSuchChangeException, IOException, OrmException;
 
   public abstract Result rebuild(NoteDbUpdateManager manager,
       ChangeBundle bundle) throws NoSuchChangeException, IOException,
-      OrmException, ConfigInvalidException;
+      OrmException;
 
   public abstract void buildUpdates(NoteDbUpdateManager manager,
       ChangeBundle bundle) throws IOException, OrmException;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
index 20524cf..c178d54 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
@@ -157,8 +157,7 @@
 
   @Override
   public Result rebuild(ReviewDb db, Change.Id changeId)
-      throws NoSuchChangeException, IOException, OrmException,
-      ConfigInvalidException {
+      throws NoSuchChangeException, IOException, OrmException {
     db = ReviewDbUtil.unwrapDb(db);
     // Read change just to get project; this instance is then discarded so we
     // can read a consistent ChangeBundle inside a transaction.
@@ -176,7 +175,7 @@
   @Override
   public Result rebuild(NoteDbUpdateManager manager,
       ChangeBundle bundle) throws NoSuchChangeException, IOException,
-      OrmException, ConfigInvalidException {
+      OrmException {
     Change change = new Change(bundle.getChange());
     buildUpdates(manager, bundle);
     return manager.stageAndApplyDelta(change);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java
index e0545a3..b9871ab 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java
@@ -40,6 +40,10 @@
 public class ListPlugins implements RestReadView<TopLevelResource> {
   private final PluginLoader pluginLoader;
 
+  @Deprecated
+  @Option(name = "--format", usage = "(deprecated) output format")
+  private OutputFormat format = OutputFormat.TEXT;
+
   @Option(name = "--all", aliases = {"-a"}, usage = "List all plugins, including disabled plugins")
   private boolean all;
 
@@ -48,12 +52,23 @@
     this.pluginLoader = pluginLoader;
   }
 
+  public OutputFormat getFormat() {
+    return format;
+  }
+
+  public ListPlugins setFormat(OutputFormat fmt) {
+    this.format = fmt;
+    return this;
+  }
+
   @Override
   public Object apply(TopLevelResource resource) {
+    format = OutputFormat.JSON;
     return display(null);
   }
 
   public JsonElement display(PrintWriter stdout) {
+    Map<String, PluginInfo> output = new TreeMap<>();
     List<Plugin> plugins = Lists.newArrayList(pluginLoader.getPlugins(all));
     Collections.sort(plugins, new Comparator<Plugin>() {
       @Override
@@ -62,24 +77,30 @@
       }
     });
 
-    if (stdout == null) {
-      Map<String, PluginInfo> output = new TreeMap<>();
-      for (Plugin p : plugins) {
-        PluginInfo info = new PluginInfo(p);
+    if (!format.isJson()) {
+      stdout.format("%-30s %-10s %-8s %s\n", "Name", "Version", "Status", "File");
+      stdout.print("-------------------------------------------------------------------------------\n");
+    }
+
+    for (Plugin p : plugins) {
+      PluginInfo info = new PluginInfo(p);
+      if (format.isJson()) {
         output.put(p.getName(), info);
+      } else {
+        stdout.format("%-30s %-10s %-8s %s\n", p.getName(),
+            Strings.nullToEmpty(info.version),
+            p.isDisabled() ? "DISABLED" : "ENABLED",
+            p.getSrcFile().getFileName());
       }
+    }
+
+    if (stdout == null) {
       return OutputFormat.JSON.newGson().toJsonTree(
           output,
           new TypeToken<Map<String, Object>>() {}.getType());
-    }
-    stdout.format("%-30s %-10s %-8s %s\n", "Name", "Version", "Status", "File");
-    stdout.print("-------------------------------------------------------------------------------\n");
-    for (Plugin p : plugins) {
-      PluginInfo info = new PluginInfo(p);
-      stdout.format("%-30s %-10s %-8s %s\n", p.getName(),
-          Strings.nullToEmpty(info.version),
-          p.isDisabled() ? "DISABLED" : "ENABLED",
-          p.getSrcFile().getFileName());
+    } else if (format.isJson()) {
+      format.newGson().toJson(output,
+          new TypeToken<Map<String, PluginInfo>>() {}.getType(), stdout);
       stdout.print('\n');
     }
     stdout.flush();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
index f7ebd77..145d1c8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
@@ -441,6 +441,18 @@
     return getRefControl().canForceEditTopicName();
   }
 
+  /** Can this user edit the description? */
+  public boolean canEditDescription() {
+    if (getChange().getStatus().isOpen()) {
+      return isOwner() // owner (aka creator) of the change can edit desc
+          || getRefControl().isOwner() // branch owner can edit desc
+          || getProjectControl().isOwner() // project owner can edit desc
+          || getUser().getCapabilities().canAdministrateServer() // site administers are god
+      ;
+    }
+    return false;
+  }
+
   public boolean canEditAssignee() {
     return isOwner()
         || getProjectControl().isOwner()
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java
index 939d8d4..41392fb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java
@@ -307,10 +307,9 @@
 
       md.setMessage("Created project\n");
       config.commit(md);
+      md.getRepository().setGitwebDescription(args.projectDescription);
     }
     projectCache.onCreateProject(args.getProject());
-    repoManager.setProjectDescription(args.getProject(),
-        args.projectDescription);
   }
 
   private List<String> normalizeBranchNames(List<String> branches)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java
index f4fa446..bcaf79a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java
@@ -16,10 +16,11 @@
 
 import static java.lang.String.format;
 
-import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.server.IdentifiedUser;
@@ -72,13 +73,10 @@
 
   @Override
   public Response<?> apply(ProjectResource project, DeleteBranchesInput input)
-      throws OrmException, IOException, ResourceConflictException {
+      throws OrmException, IOException, RestApiException {
 
-    if (input == null) {
-      input = new DeleteBranchesInput();
-    }
-    if (input.branches == null) {
-      input.branches = Lists.newArrayListWithCapacity(1);
+    if (input == null || input.branches == null || input.branches.isEmpty()) {
+      throw new BadRequestException("branches must be specified");
     }
 
     try (Repository r = repoManager.openRepository(project.getNameKey())) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java
index a50705d..2da0e01 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.collect.ComparisonChain;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.common.ActionInfo;
@@ -191,10 +190,10 @@
       }
       info.actions.put(d.getId(), new ActionInfo(d));
     }
-    FluentIterable<WebLinkInfo> links =
+    List<WebLinkInfo> links =
         webLinks.getBranchLinks(
             refControl.getProjectControl().getProject().getName(), ref.getName());
-    info.webLinks = links.isEmpty() ? null : links.toList();
+    info.webLinks = links.isEmpty() ? null : links;
     return info;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java
index 1ea0c62..92189dd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java
@@ -17,7 +17,6 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.GroupReference;
@@ -378,9 +377,9 @@
             log.warn("Unexpected error reading " + projectName, err);
             continue;
           }
-          FluentIterable<WebLinkInfo> links =
+          List<WebLinkInfo> links =
               webLinks.getProjectLinks(projectName.get());
-          info.webLinks = links.isEmpty() ? null : links.toList();
+          info.webLinks = links.isEmpty() ? null : links;
         }
 
         if (foundIndex++ < start) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
index d9cc59c..ca01630 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
@@ -42,6 +42,8 @@
 import com.google.gerrit.server.git.VisibleRefFilter;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -153,6 +155,7 @@
   private final Collection<ContributorAgreement> contributorAgreements;
   private final TagCache tagCache;
   @Nullable private final SearchingChangeCacheImpl changeCache;
+  private final Provider<InternalChangeQuery> queryProvider;
 
   private List<SectionMatcher> allSections;
   private List<SectionMatcher> localSections;
@@ -168,6 +171,7 @@
       ChangeNotes.Factory changeNotesFactory,
       ChangeControl.Factory changeControlFactory,
       TagCache tagCache,
+      Provider<InternalChangeQuery> queryProvider,
       @Nullable SearchingChangeCacheImpl changeCache,
       @CanonicalWebUrl @Nullable String canonicalWebUrl,
       @Assisted CurrentUser who,
@@ -181,6 +185,7 @@
     this.permissionFilter = permissionFilter;
     this.contributorAgreements = pc.getAllProjects().getConfig().getContributorAgreements();
     this.canonicalWebUrl = canonicalWebUrl;
+    this.queryProvider = queryProvider;
     user = who;
     state = ps;
   }
@@ -513,7 +518,27 @@
     return false;
   }
 
+  /** @return whether a commit is visible to user. */
   public boolean canReadCommit(ReviewDb db, Repository repo, RevCommit commit) {
+    // Look for changes associated with the commit.
+    try {
+      List<ChangeData> changes = queryProvider.get()
+          .byProjectCommit(getProject().getNameKey(), commit);
+      for (ChangeData change : changes) {
+        if (controlFor(db, change.change()).isVisible(db)) {
+          return true;
+        }
+      }
+    } catch (OrmException e) {
+      log.error("Cannot look up change for commit " + commit.name() + " in "
+          + getProject().getName(), e);
+    }
+    // Scan all visible refs.
+    return canReadCommitFromVisibleRef(db, repo, commit);
+  }
+
+  private boolean canReadCommitFromVisibleRef(ReviewDb db, Repository repo,
+      RevCommit commit) {
     try (RevWalk rw = new RevWalk(repo)) {
       return isMergedIntoVisibleRef(repo, db, rw, commit,
           repo.getAllRefs().values());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectJson.java
index 5b1d521..767e36a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectJson.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.FluentIterable;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.restapi.Url;
@@ -25,6 +24,8 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+import java.util.List;
+
 @Singleton
 public class ProjectJson {
 
@@ -50,9 +51,9 @@
     info.description = Strings.emptyToNull(p.getDescription());
     info.state = p.getState();
     info.id = Url.encode(info.name);
-    FluentIterable<WebLinkInfo> links =
+    List<WebLinkInfo> links =
         webLinks.getProjectLinks(p.getName());
-    info.webLinks = links.isEmpty() ? null : links.toList();
+    info.webLinks = links.isEmpty() ? null : links;
     return info;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java
index 19b5b26..bf4cbbf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java
@@ -34,7 +34,6 @@
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
-import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.git.TransferConfig;
@@ -60,7 +59,6 @@
   private final boolean serverEnableSignedPush;
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final ProjectCache projectCache;
-  private final GitRepositoryManager gitMgr;
   private final ProjectState.Factory projectStateFactory;
   private final TransferConfig config;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
@@ -73,7 +71,6 @@
   PutConfig(@EnableSignedPush boolean serverEnableSignedPush,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       ProjectCache projectCache,
-      GitRepositoryManager gitMgr,
       ProjectState.Factory projectStateFactory,
       TransferConfig config,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
@@ -84,7 +81,6 @@
     this.serverEnableSignedPush = serverEnableSignedPush;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.projectCache = projectCache;
-    this.gitMgr = gitMgr;
     this.projectStateFactory = projectStateFactory;
     this.config = config;
     this.pluginConfigEntries = pluginConfigEntries;
@@ -170,7 +166,7 @@
       try {
         projectConfig.commit(md);
         projectCache.evict(projectConfig.getProject());
-        gitMgr.setProjectDescription(projectName, p.getDescription());
+        md.getRepository().setGitwebDescription(p.getDescription());
       } catch (IOException e) {
         if (e.getCause() instanceof ConfigInvalidException) {
           throw new ResourceConflictException("Cannot update " + projectName
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutDescription.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutDescription.java
index 17401fe..99f0b83 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutDescription.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutDescription.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.inject.Inject;
@@ -39,15 +38,12 @@
 public class PutDescription implements RestModifyView<ProjectResource, DescriptionInput> {
   private final ProjectCache cache;
   private final MetaDataUpdate.Server updateFactory;
-  private final GitRepositoryManager gitMgr;
 
   @Inject
   PutDescription(ProjectCache cache,
-      MetaDataUpdate.Server updateFactory,
-      GitRepositoryManager gitMgr) {
+      MetaDataUpdate.Server updateFactory) {
     this.cache = cache;
     this.updateFactory = updateFactory;
-    this.gitMgr = gitMgr;
   }
 
   @Override
@@ -79,9 +75,7 @@
       md.setMessage(msg);
       config.commit(md);
       cache.evict(ctl.getProject());
-      gitMgr.setProjectDescription(
-          resource.getNameKey(),
-          project.getDescription());
+      md.getRepository().setGitwebDescription(project.getDescription());
 
       return Strings.isNullOrEmpty(project.getDescription())
           ? Response.<String>none()
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java
index b3f92ff..9a9ec5d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java
@@ -47,7 +47,7 @@
     return Predicate.or(preds);
   }
 
-  static Predicate<AccountState> id(Account.Id accountId) {
+  public static Predicate<AccountState> id(Account.Id accountId) {
     return new AccountPredicate(AccountField.ID,
         AccountQueryBuilder.FIELD_ACCOUNT, accountId.toString());
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
index 3387f06..085f34c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -23,6 +23,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
@@ -51,6 +52,7 @@
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.StarRef;
 import com.google.gerrit.server.change.MergeabilityCache;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
@@ -87,7 +89,6 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
@@ -346,17 +347,21 @@
   private SubmitTypeRecord submitTypeRecord;
   private Boolean mergeable;
   private Set<String> hashtags;
-  private Set<Account.Id> editsByUser;
+  private Map<Account.Id, Ref> editsByUser;
   private Set<Account.Id> reviewedBy;
-  private Set<Account.Id> draftsByUser;
+  private Map<Account.Id, Ref> draftsByUser;
   @Deprecated
   private Set<Account.Id> starredByUser;
   private ImmutableMultimap<Account.Id, String> stars;
+  private ImmutableMap<Account.Id, StarRef> starRefs;
   private ReviewerSet reviewers;
   private List<ReviewerStatusUpdate> reviewerUpdates;
   private PersonIdent author;
   private PersonIdent committer;
 
+  private ImmutableList<byte[]> refStates;
+  private ImmutableList<byte[]> refStatePatterns;
+
   @AssistedInject
   private ChangeData(
       GitRepositoryManager repoManager,
@@ -760,11 +765,12 @@
   }
 
   public Change reloadChange() throws OrmException {
-    notes = notesFactory.create(db, project, legacyId);
-    change = notes.getChange();
-    if (change == null) {
-      throw new OrmException("Unable to load change " + legacyId);
+    try {
+      notes = notesFactory.createChecked(db, project, legacyId);
+    } catch (NoSuchChangeException e) {
+      throw new OrmException("Unable to load change " + legacyId, e);
     }
+    change = notes.getChange();
     setPatchSets(null);
     return change;
   }
@@ -1095,21 +1101,25 @@
   }
 
   public Set<Account.Id> editsByUser() throws OrmException {
+    return editRefs().keySet();
+  }
+
+  public Map<Account.Id, Ref> editRefs() throws OrmException {
     if (editsByUser == null) {
       if (!lazyLoad) {
-        return Collections.emptySet();
+        return Collections.emptyMap();
       }
       Change c = change();
       if (c == null) {
-        return Collections.emptySet();
+        return Collections.emptyMap();
       }
-      editsByUser = new HashSet<>();
+      editsByUser = new HashMap<>();
       Change.Id id = checkNotNull(change.getId());
       try (Repository repo = repoManager.openRepository(project())) {
-        for (String ref
-            : repo.getRefDatabase().getRefs(RefNames.REFS_USERS).keySet()) {
-          if (id.equals(Change.Id.fromEditRefPart(ref))) {
-            editsByUser.add(Account.Id.fromRefPart(ref));
+        for (Map.Entry<String, Ref> e
+            : repo.getRefDatabase().getRefs(RefNames.REFS_USERS).entrySet()) {
+          if (id.equals(Change.Id.fromEditRefPart(e.getKey()))) {
+            editsByUser.put(Account.Id.fromRefPart(e.getKey()), e.getValue());
           }
         }
       } catch (IOException e) {
@@ -1120,17 +1130,31 @@
   }
 
   public Set<Account.Id> draftsByUser() throws OrmException {
+    return draftRefs().keySet();
+  }
+
+  public Map<Account.Id, Ref> draftRefs() throws OrmException {
     if (draftsByUser == null) {
       if (!lazyLoad) {
-        return Collections.emptySet();
+        return Collections.emptyMap();
       }
       Change c = change();
       if (c == null) {
-        return Collections.emptySet();
+        return Collections.emptyMap();
       }
-      draftsByUser = new HashSet<>();
-      for (Comment sc : commentsUtil.draftByChange(db, notes)) {
-        draftsByUser.add(sc.author.getId());
+
+      draftsByUser = new HashMap<>();
+      if (notesMigration.readChanges()) {
+        for (Ref ref : commentsUtil.getDraftRefs(notes.getChangeId())) {
+          Account.Id account = Account.Id.fromRefSuffix(ref.getName());
+          if (account != null) {
+            draftsByUser.put(account, ref);
+          }
+        }
+      } else {
+        for (Comment sc : commentsUtil.draftByChange(db, notes())) {
+          draftsByUser.put(sc.author.getId(), null);
+        }
       }
     }
     return draftsByUser;
@@ -1204,7 +1228,12 @@
       if (!lazyLoad) {
         return ImmutableMultimap.of();
       }
-      stars = checkNotNull(starredChangesUtil).byChange(legacyId);
+      ImmutableMultimap.Builder<Account.Id, String> b =
+          ImmutableMultimap.builder();
+      for (Map.Entry<Account.Id, StarRef> e : starRefs().entrySet()) {
+        b.putAll(e.getKey(), e.getValue().labels());
+      }
+      return b.build();
     }
     return stars;
   }
@@ -1213,6 +1242,16 @@
     this.stars = ImmutableMultimap.copyOf(stars);
   }
 
+  public ImmutableMap<Account.Id, StarRef> starRefs() throws OrmException {
+    if (starRefs == null) {
+      if (!lazyLoad) {
+        return ImmutableMap.of();
+      }
+      starRefs = checkNotNull(starredChangesUtil).byChange(legacyId);
+    }
+    return starRefs;
+  }
+
   @AutoValue
   abstract static class ReviewedByEvent {
     private static ReviewedByEvent create(ChangeMessage msg) {
@@ -1244,4 +1283,20 @@
       this.deletions = deletions;
     }
   }
+
+  public ImmutableList<byte[]> getRefStates() {
+    return refStates;
+  }
+
+  public void setRefStates(Iterable<byte[]> refStates) {
+    this.refStates = ImmutableList.copyOf(refStates);
+  }
+
+  public ImmutableList<byte[]> getRefStatePatterns() {
+    return refStatePatterns;
+  }
+
+  public void setRefStatePatterns(Iterable<byte[]> refStatePatterns) {
+    this.refStatePatterns = ImmutableList.copyOf(refStatePatterns);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index e62bf48..73951c4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -98,6 +98,27 @@
       extends OperatorFactory<ChangeData, ChangeQueryBuilder> {
   }
 
+  /**
+   * Converts a operand (operator value) passed to an operator into a
+   *  {@link Predicate}.
+   *
+   * Register a ChangeOperandFactory in a config Module like this (note, for
+   * an example we are using the has predicate, when other predicate plugin
+   * operands are created they can be registered in a similar manner):
+   *
+   *   bind(ChangeHasOperandFactory.class)
+   *      .annotatedWith(Exports.named("your has operand"))
+   *      .to(YourClass.class);
+   *
+   */
+  private interface ChangeOperandFactory {
+    Predicate<ChangeData> create(ChangeQueryBuilder builder)
+        throws QueryParseException;
+  }
+
+  public interface ChangeHasOperandFactory extends ChangeOperandFactory {
+  }
+
   private static final Pattern PAT_LEGACY_ID = Pattern.compile("^[1-9][0-9]*$");
   private static final Pattern PAT_CHANGE_ID = Pattern.compile(CHANGE_ID_PATTERN);
   private static final Pattern DEF_CHANGE = Pattern.compile(
@@ -166,6 +187,7 @@
     final Provider<InternalChangeQuery> queryProvider;
     final ChangeIndexRewriter rewriter;
     final DynamicMap<ChangeOperatorFactory> opFactories;
+    final DynamicMap<ChangeHasOperandFactory> hasOperands;
     final IdentifiedUser.GenericFactory userFactory;
     final CapabilityControl.Factory capabilityControlFactory;
     final ChangeControl.GenericFactory changeControlGenericFactory;
@@ -199,6 +221,7 @@
         Provider<InternalChangeQuery> queryProvider,
         ChangeIndexRewriter rewriter,
         DynamicMap<ChangeOperatorFactory> opFactories,
+        DynamicMap<ChangeHasOperandFactory> hasOperands,
         IdentifiedUser.GenericFactory userFactory,
         Provider<CurrentUser> self,
         CapabilityControl.Factory capabilityControlFactory,
@@ -224,13 +247,13 @@
         StarredChangesUtil starredChangesUtil,
         AccountCache accountCache,
         @GerritServerConfig Config cfg) {
-      this(db, queryProvider, rewriter, opFactories, userFactory, self,
-          capabilityControlFactory, changeControlGenericFactory, notesFactory,
+      this(db, queryProvider, rewriter, opFactories, hasOperands, userFactory,
+          self, capabilityControlFactory, changeControlGenericFactory, notesFactory,
           changeDataFactory, fillArgs, commentsUtil, accountResolver, groupBackend,
-          allProjectsName, allUsersName, patchListCache, repoManager,
-          projectCache, listChildProjects, submitDryRun, conflictsCache,
-          trackingFooters, indexes != null ? indexes.getSearchIndex() : null,
-          indexConfig, listMembers, starredChangesUtil, accountCache,
+          allProjectsName, allUsersName, patchListCache, repoManager, projectCache,
+          listChildProjects, submitDryRun, conflictsCache, trackingFooters,
+          indexes != null ? indexes.getSearchIndex() : null, indexConfig, listMembers,
+          starredChangesUtil, accountCache,
           cfg == null ? true : cfg.getBoolean("change", "allowDrafts", true));
     }
 
@@ -239,6 +262,7 @@
         Provider<InternalChangeQuery> queryProvider,
         ChangeIndexRewriter rewriter,
         DynamicMap<ChangeOperatorFactory> opFactories,
+        DynamicMap<ChangeHasOperandFactory> hasOperands,
         IdentifiedUser.GenericFactory userFactory,
         Provider<CurrentUser> self,
         CapabilityControl.Factory capabilityControlFactory,
@@ -293,15 +317,16 @@
      this.starredChangesUtil = starredChangesUtil;
      this.accountCache = accountCache;
      this.allowsDrafts = allowsDrafts;
+     this.hasOperands = hasOperands;
     }
 
     Arguments asUser(CurrentUser otherUser) {
-      return new Arguments(db, queryProvider, rewriter, opFactories, userFactory,
-          Providers.of(otherUser),
+      return new Arguments(db, queryProvider, rewriter, opFactories,
+          hasOperands, userFactory, Providers.of(otherUser),
           capabilityControlFactory, changeControlGenericFactory, notesFactory,
-          changeDataFactory, fillArgs, commentsUtil, accountResolver, groupBackend,
-          allProjectsName, allUsersName, patchListCache, repoManager,
-          projectCache, listChildProjects, submitDryRun,
+          changeDataFactory, fillArgs, commentsUtil, accountResolver,
+          groupBackend, allProjectsName, allUsersName, patchListCache,
+          repoManager, projectCache, listChildProjects, submitDryRun,
           conflictsCache, trackingFooters, index, indexConfig, listMembers,
           starredChangesUtil, accountCache, allowsDrafts);
     }
@@ -453,6 +478,16 @@
     if ("edit".equalsIgnoreCase(value)) {
       return new EditByPredicate(self());
     }
+
+    // for plugins the value will be operandName_pluginName
+    String[] names = value.split("_");
+    if (names.length == 2) {
+      ChangeHasOperandFactory op = args.hasOperands.get(names[1], names[0]);
+      if (op != null) {
+        return op.create(this);
+      }
+    }
+
     throw new IllegalArgumentException();
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 6721052..0bd1800 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -242,6 +242,11 @@
   }
 
   public List<ChangeData> byProjectCommit(Project.NameKey project,
+      ObjectId id) throws OrmException {
+    return byProjectCommit(project, id.name());
+  }
+
+  public List<ChangeData> byProjectCommit(Project.NameKey project,
       String hash) throws OrmException {
     return query(and(project(project), commit(hash)));
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
index 425eb00..f7f98d5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
@@ -22,7 +22,7 @@
 public class LegacyChangeIdPredicate extends ChangeIndexPredicate {
   private final Change.Id id;
 
-  LegacyChangeIdPredicate(Change.Id id) {
+  public LegacyChangeIdPredicate(Change.Id id) {
     super(LEGACY_ID, ChangeQueryBuilder.FIELD_CHANGE, id.toString());
     this.id = id;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/BaseDataSourceType.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/BaseDataSourceType.java
index fd1502d..bf87ee0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/BaseDataSourceType.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/BaseDataSourceType.java
@@ -21,6 +21,7 @@
 
 public abstract class BaseDataSourceType implements DataSourceType {
 
+  private static final String DEFAULT_VALIDATION_QUERY = "select 1";
   private final String driver;
 
   protected BaseDataSourceType(String driver) {
@@ -38,6 +39,11 @@
   }
 
   @Override
+  public String getValidationQuery() {
+    return DEFAULT_VALIDATION_QUERY;
+  }
+
+  @Override
   public ScriptRunner getIndexScript() throws IOException {
     return getScriptRunner("index_generic.sql");
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DB2.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DB2.java
index 4ad8e2f..4f0b63f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DB2.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DB2.java
@@ -43,4 +43,9 @@
     b.append(dbc.required("database"));
     return b.toString();
   }
+
+  @Override
+  public String getValidationQuery() {
+    return "SELECT 1 FROM SYSIBM.SYSDUMMY1";
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java
index ab4dfc7..69f4ba5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java
@@ -137,6 +137,8 @@
       ds.setMaxWait(ConfigUtil.getTimeUnit(cfg, "database", null,
           "poolmaxwait", MILLISECONDS.convert(30, SECONDS), MILLISECONDS));
       ds.setInitialSize(ds.getMinIdle());
+      ds.setValidationQuery(dst.getValidationQuery());
+      ds.setValidationQueryTimeout(5);
       exportPoolMetrics(ds);
       return intercept(interceptor, ds);
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceType.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceType.java
index 6eaa540..ee8ce81 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceType.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceType.java
@@ -24,6 +24,8 @@
 
   String getUrl();
 
+  String getValidationQuery();
+
   boolean usePool();
 
   /**
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Derby.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Derby.java
index dd300c6..f98e83b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Derby.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Derby.java
@@ -41,4 +41,9 @@
     }
     return "jdbc:derby:" + site.resolve(database).toString() + ";create=true";
   }
+
+  @Override
+  public String getValidationQuery() {
+    return "values 1";
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Oracle.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Oracle.java
index bb4c477..e7d3390 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Oracle.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Oracle.java
@@ -43,4 +43,9 @@
     b.append(dbc.required("instance"));
     return b.toString();
   }
+
+  @Override
+  public String getValidationQuery() {
+    return "select 1 from dual";
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
index 2d9714f..504767c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
@@ -103,4 +103,8 @@
       throw new OrmException(e);
     }
   }
+
+  public boolean isUpdated() {
+    return updated;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
index 87b9eff..cc37c97 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
@@ -36,7 +36,7 @@
 /** A version of the database schema. */
 public abstract class SchemaVersion {
   /** The current schema version. */
-  public static final Class<Schema_136> C = Schema_136.class;
+  public static final Class<Schema_137> C = Schema_137.class;
 
   public static int getBinaryVersion() {
     return guessVersion(C);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_130.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_130.java
index cee21bd..5dcd981 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_130.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_130.java
@@ -29,6 +29,9 @@
 import org.eclipse.jgit.lib.Repository;
 
 import java.io.IOException;
+import java.util.SortedSet;
+import java.util.TreeSet;
+import java.util.stream.Collectors;
 
 public class Schema_130 extends SchemaVersion {
   private static final String COMMIT_MSG =
@@ -51,16 +54,27 @@
 
   @Override
   protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
-    for (Project.NameKey projectName : repoManager.list()) {
+    SortedSet<Project.NameKey> repoList = repoManager.list();
+    SortedSet<Project.NameKey> repoUpgraded = new TreeSet<>();
+    ui.message("\tMigrating " + repoList.size() + " repositories ...");
+    for (Project.NameKey projectName : repoList) {
       try (Repository git = repoManager.openRepository(projectName);
           MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED,
               projectName, git)) {
         ProjectConfigSchemaUpdate cfg = ProjectConfigSchemaUpdate.read(md);
         cfg.removeForceFromPermission("pushTag");
+        if (cfg.isUpdated()) {
+          repoUpgraded.add(projectName);
+        }
         cfg.save(serverUser, COMMIT_MSG);
       } catch (ConfigInvalidException | IOException ex) {
         throw new OrmException("Cannot migrate project " + projectName, ex);
       }
     }
+    ui.message("\tMigration completed:  " + repoUpgraded.size()
+        + " repositories updated:");
+    ui.message("\t"
+        + repoUpgraded.stream().map(n -> n.get())
+            .collect(Collectors.joining(" ")));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_131.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_131.java
index a2ba03c..4e581c8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_131.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_131.java
@@ -30,6 +30,9 @@
 import org.eclipse.jgit.lib.Repository;
 
 import java.io.IOException;
+import java.util.SortedSet;
+import java.util.TreeSet;
+import java.util.stream.Collectors;
 
 public class Schema_131 extends SchemaVersion {
   private static final String COMMIT_MSG =
@@ -49,7 +52,10 @@
 
   @Override
   protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
-    for (Project.NameKey projectName : repoManager.list()) {
+    SortedSet<Project.NameKey> repoList = repoManager.list();
+    SortedSet<Project.NameKey> repoUpgraded = new TreeSet<>();
+    ui.message("\tMigrating " + repoList.size() + " repositories ...");
+    for (Project.NameKey projectName : repoList) {
       try (Repository git = repoManager.openRepository(projectName);
           MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED,
               projectName, git)) {
@@ -59,10 +65,16 @@
           md.getCommitBuilder().setCommitter(serverUser);
           md.setMessage(COMMIT_MSG);
           config.commit(md);
+          repoUpgraded.add(projectName);
         }
       } catch (ConfigInvalidException | IOException ex) {
         throw new OrmException("Cannot migrate project " + projectName, ex);
       }
     }
+    ui.message("\tMigration completed:  " + repoUpgraded.size()
+        + " repositories updated:");
+    ui.message("\t"
+        + repoUpgraded.stream().map(n -> n.get())
+            .collect(Collectors.joining(" ")));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_137.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_137.java
new file mode 100644
index 0000000..1b4102f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_137.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+/* change the type of SystemConfig#sitePath to CLOB */
+public class Schema_137 extends SchemaVersion {
+  @Inject
+  Schema_137(Provider<Schema_136> prior) {
+    super(prior);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/HostPlatform.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/HostPlatform.java
index 86b3b7364..074df0c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/HostPlatform.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/HostPlatform.java
@@ -18,14 +18,19 @@
 import java.security.PrivilegedAction;
 
 public final class HostPlatform {
-  private static final boolean win32 = computeWin32();
+  private static final boolean win32 = compute("windows");
+  private static final boolean mac = compute("mac");
 
   /** @return true if this JVM is running on a Windows platform. */
   public static boolean isWin32() {
     return win32;
   }
 
-  private static boolean computeWin32() {
+  public static boolean isMac() {
+    return mac;
+  }
+
+  private static boolean compute(String platform) {
     final String osDotName =
         AccessController.doPrivileged(new PrivilegedAction<String>() {
           @Override
@@ -34,7 +39,7 @@
           }
         });
     return osDotName != null
-        && osDotName.toLowerCase().contains("windows");
+        && osDotName.toLowerCase().contains(platform);
   }
 
   private HostPlatform() {
diff --git a/gerrit-server/src/main/java/gerrit/PRED_uploader_1.java b/gerrit-server/src/main/java/gerrit/PRED_uploader_1.java
index bea7c8b..06977b3 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_uploader_1.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_uploader_1.java
@@ -15,6 +15,7 @@
 package gerrit;
 
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.rules.StoredValues;
 
 import com.googlecode.prolog_cafe.exceptions.PrologException;
@@ -26,7 +27,13 @@
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 public class PRED_uploader_1 extends Predicate.P1 {
+  private static final Logger log =
+      LoggerFactory.getLogger(PRED_uploader_1.class);
+
   private static final SymbolTerm user = SymbolTerm.intern("user", 1);
 
   public PRED_uploader_1(Term a1, Operation n) {
@@ -39,7 +46,14 @@
     engine.setB0();
     Term a1 = arg1.dereference();
 
-    Account.Id uploaderId = StoredValues.getPatchSet(engine).getUploader();
+    PatchSet patchSet = StoredValues.getPatchSet(engine);
+    if (patchSet == null) {
+      log.error("Failed to load current patch set of change "
+          + StoredValues.getChange(engine).getChangeId());
+      return engine.fail();
+    }
+
+    Account.Id uploaderId = patchSet.getUploader();
 
     if (!a1.unify(new StructureTerm(user, new IntegerTerm(uploaderId.get())),
         engine.trail)) {
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/change/ChangeMessages.properties b/gerrit-server/src/main/resources/com/google/gerrit/server/change/ChangeMessages.properties
index f05f23b..f34c992 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/change/ChangeMessages.properties
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/change/ChangeMessages.properties
@@ -1,7 +1,8 @@
 # Changes to this file should also be made in
 # gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.properties
 revertChangeDefaultMessage = Revert \"{0}\"\n\nThis reverts commit {1}.
-reviewerNotFound = {0} does not identify a registered user or group
+reviewerNotFoundUser = {0} does not identify a registered user
+reviewerNotFoundUserOrGroup = {0} does not identify a registered user or group
 
 groupIsNotAllowed =  The group {0} cannot be added as reviewer.
 groupHasTooManyMembers = The group {0} has too many members to add them all as reviewers.
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AbandonedHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
index e3b4613..c7d4699 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
@@ -17,14 +17,13 @@
 {namespace com.google.gerrit.server.mail.template}
 
 /**
- * @param change
  * @param coverLetter
  * @param email
  * @param fromName
  */
 {template .AbandonedHtml autoescape="strict" kind="html"}
   <p>
-    {$fromName} abandoned <strong>{$change.subject}</strong>.
+    {$fromName} <strong>abandoned</strong> this change.
   </p>
 
   {if $email.changeUrl}
@@ -34,6 +33,6 @@
   {/if}
 
   {if $coverLetter}
-    {call .Pre}{param content: $coverLetter /}{/call}
+    <div style="white-space:pre-wrap">{$coverLetter}</div>
   {/if}
 {/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooter.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooter.soy
index 4958bde..a034872 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooter.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooter.soy
@@ -19,15 +19,7 @@
 /**
  * The .ChangeFooter template will determine the contents of the footer text
  * that will be appended to ALL emails related to changes.
- * @param branch
- * @param ccEmails
- * @param change
- * @param changeId
  * @param email
- * @param messageClass
- * @param patchSet
- * @param projectName
- * @param reviewerEmails
  */
 {template .ChangeFooter autoescape="strict" kind="text"}
   --{sp}
@@ -44,18 +36,4 @@
   {if $email.changeUrl or $email.settingsUrl}
     {\n}
   {/if}
-
-  Gerrit-MessageType: {$messageClass}{\n}
-  Gerrit-Change-Id: {$changeId}{\n}
-  Gerrit-Change-Number: {$change.changeNumber}{\n}
-  Gerrit-PatchSet: {$patchSet.patchSetId}{\n}
-  Gerrit-Project: {$projectName}{\n}
-  Gerrit-Branch: {$branch.shortName}{\n}
-  Gerrit-Owner: {$change.ownerEmail}{\n}
-  {foreach $reviewer in $reviewerEmails}
-    Gerrit-Reviewer: {$reviewer}{\n}
-  {/foreach}
-  {foreach $reviewer in $ccEmails}
-    Gerrit-CC: {$reviewer}{\n}
-  {/foreach}
 {/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
index 28b2c28..5091cfe 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
@@ -17,21 +17,9 @@
 {namespace com.google.gerrit.server.mail.template}
 
 /**
- * @param branch
- * @param ccEmails
- * @param change
- * @param changeId
  * @param email
- * @param messageClass
- * @param patchSet
- * @param projectName
- * @param reviewerEmails
  */
 {template .ChangeFooterHtml autoescape="strict" kind="html"}
-  {let $footerStyle kind="css"}
-    display: none;
-  {/let}
-
   {if $email.changeUrl or $email.settingsUrl}
     <p>
       {if $email.changeUrl}
@@ -44,22 +32,6 @@
     </p>
   {/if}
 
-  <p style="{$footerStyle}">
-    Gerrit-MessageType: {$messageClass}<br/>
-    Gerrit-Change-Id: {$changeId}<br/>
-    Gerrit-Change-Number: {$change.changeNumber}<br/>
-    Gerrit-PatchSet: {$patchSet.patchSetId}<br/>
-    Gerrit-Project: {$projectName}<br/>
-    Gerrit-Branch: {$branch.shortName}<br/>
-    Gerrit-Owner: {$change.ownerEmail}
-    {foreach $reviewer in $reviewerEmails}
-      Gerrit-Reviewer: {$reviewer}</br>
-    {/foreach}
-    {foreach $reviewer in $ccEmails}
-      Gerrit-CC: {$reviewer}</br>
-    {/foreach}
-  </p>
-
   {if $email.changeUrl}
     <div itemscope itemtype="http://schema.org/EmailMessage">
       <div itemscope itemprop="action" itemtype="http://schema.org/ViewAction">
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooter.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooter.soy
index 7ef58b7..73fdfba 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooter.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooter.soy
@@ -20,12 +20,6 @@
  * The .CommentFooter template will determine the contents of the footer text
  * that will be appended to emails related to a user submitting comments on
  * changes.
- * @param commentFiles
  */
 {template .CommentFooter autoescape="strict" kind="text"}
-  {if length($commentFiles) > 0}
-    Gerrit-HasComments: Yes
-  {else}
-    Gerrit-HasComments: No
-  {/if}
 {/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy
index 84cace2..7bf28e7 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy
@@ -16,19 +16,5 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-/**
- * @param commentFiles
- */
 {template .CommentFooterHtml autoescape="strict" kind="html"}
-  {let $footerStyle kind="css"}
-    display: none;
-  {/let}
-
-  <p style="{$footerStyle}">
-    {if length($commentFiles) > 0}
-      Gerrit-HasComments: Yes
-    {else}
-      Gerrit-HasComments: No
-    {/if}
-  </p>
 {/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy
index 6dc37b8..75492d6 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy
@@ -17,9 +17,9 @@
 {namespace com.google.gerrit.server.mail.template}
 
 /**
- * @param change
  * @param commentFiles
  * @param coverLetter
+ * @param coverLetterBlocks
  * @param email
  * @param fromName
  */
@@ -34,17 +34,13 @@
     padding: 0 10px;
   {/let}
 
-  {let $messageStyle kind="css"}
-    white-space: pre-wrap;
-  {/let}
-
   {let $ulStyle kind="css"}
     list-style: none;
     padding-left: 20px;
   {/let}
 
   <p>
-    {$fromName} posted comments on <strong>{$change.subject}</strong>.
+    {$fromName} <strong>posted comments</strong> on this change.
   </p>
 
   {if $email.changeUrl}
@@ -54,7 +50,7 @@
   {/if}
 
   {if $coverLetter}
-    <div style="white-space:pre-wrap">{$coverLetter}</div>
+    {call .WikiFormat}{param content: $coverLetterBlocks /}{/call}
   {/if}
 
   <ul style="{$ulStyle}">
@@ -115,9 +111,7 @@
                 </p>
               {/if}
 
-              <p style="{$messageStyle}">
-                {$comment.message}
-              </p>
+              {call .WikiFormat}{param content: $comment.messageBlocks /}{/call}
             </li>
           {/foreach}
         </ul>
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
index 6817fbd..5faa411 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
@@ -17,19 +17,22 @@
 {namespace com.google.gerrit.server.mail.template}
 
 /**
- * @param change
- * @param coverLetter
  * @param email
  * @param fromName
  */
 {template .DeleteReviewerHtml autoescape="strict" kind="html"}
   <p>
-    {$fromName} removed{sp}
-    {foreach $reviewerName in $email.reviewerNames}
-      {if not isFirst($reviewerName)},{sp}{/if}
-      {$reviewerName}
-    {/foreach}{sp}
-    from <strong>{$change.subject}</strong>.
+    {$fromName}{sp}
+    <strong>
+      removed{sp}
+      {foreach $reviewerName in $email.reviewerNames}
+        {if not isFirst($reviewerName)}
+          {if isLast($reviewerName)}{sp}and{else},{/if}{sp}
+        {/if}
+        {$reviewerName}
+      {/foreach}
+    </strong>{sp}
+    from this change.
   </p>
 
   {if $email.changeUrl}
@@ -37,8 +40,4 @@
       {call .ViewChangeButton data="all" /}
     </p>
   {/if}
-
-  {if $coverLetter}
-    {call .Pre}{param content: $coverLetter /}{/call}
-  {/if}
 {/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
index bfcd8d5..3d76ae2 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
@@ -17,14 +17,13 @@
 {namespace com.google.gerrit.server.mail.template}
 
 /**
- * @param change
  * @param coverLetter
  * @param email
  * @param fromName
  */
 {template .DeleteVoteHtml autoescape="strict" kind="html"}
   <p>
-    {$fromName} removed a vote from <strong>{$change.subject}</strong>.
+    {$fromName} <strong>removed a vote</strong> from this change.
   </p>
 
   {if $email.changeUrl}
@@ -34,6 +33,6 @@
   {/if}
 
   {if $coverLetter}
-    {call .Pre}{param content: $coverLetter /}{/call}
+    <div style="white-space:pre-wrap">{$coverLetter}</div>
   {/if}
 {/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.soy
index 6467e95..24db2fd 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.soy
@@ -20,6 +20,10 @@
  * The .Footer template will determine the contents of the footer text
  * appended to the end of all outgoing emails after the ChangeFooter and
  * CommentFooter.
+ * @param footers
  */
-{template .Footer}
+{template .Footer autoescape="strict" kind="text"}
+  {foreach $footer in $footers}
+    {$footer}{\n}
+  {/foreach}
 {/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/FooterHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/FooterHtml.soy
index 9befa51..9f9c503 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/FooterHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/FooterHtml.soy
@@ -16,5 +16,14 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
+/**
+ * @param footers
+ */
 {template .FooterHtml autoescape="strict" kind="html"}
+  {\n}
+  {\n}
+  {foreach $footer in $footers}
+    <div style="display:none">{sp}{$footer}{sp}</div>{\n}
+  {/foreach}
+  {\n}
 {/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy
index 257f522..fa2b44d 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy
@@ -17,13 +17,12 @@
 {namespace com.google.gerrit.server.mail.template}
 
 /**
- * @param change
  * @param email
  * @param fromName
  */
 {template .MergedHtml autoescape="strict" kind="html"}
   <p>
-    {$fromName} merged <strong>{$change.subject}</strong>.
+    {$fromName} <strong>merged</strong> this change.
   </p>
 
   {if $email.changeUrl}
@@ -32,9 +31,9 @@
     </p>
   {/if}
 
-  {call .Pre}{param content: $email.changeDetail /}{/call}
+  <div style="white-space:pre-wrap">{$email.approvals}</div>
 
-  {call .Pre}{param content: $email.approvals /}{/call}
+  {call .Pre}{param content: $email.changeDetail /}{/call}
 
   {if $email.includeDiff}
     {call .Pre}{param content: $email.unifiedDiff /}{/call}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
index 63d3462..eef3a7e 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
@@ -17,7 +17,6 @@
 {namespace com.google.gerrit.server.mail.template}
 
 /**
- * @param change
  * @param email
  * @param fromName
  * @param patchSet
@@ -33,9 +32,9 @@
         {/if}
         {$reviewerName}
       {/foreach}{sp}
-      to review <strong>{$change.subject}</strong>.
+      to <strong>review</strong> this change.
     {else}
-      {$fromName} uploaded <strong>{$change.subject}</strong> for review.
+      {$fromName} uploaded this change for <strong>review</strong>.
     {/if}
   </p>
 
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
index 88cd8d0..7c12325 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
@@ -41,3 +41,43 @@
   <pre style="{$preStyle}">{$content}</pre>
 {/template}
 
+/**
+ * Take a list of unescaped comment blocks and emit safely escaped HTML to
+ * render it nicely with wiki-like format.
+ *
+ * Each block is a map with a type key. When the type is 'paragraph', or 'pre',
+ * it also has a 'text' key that maps to the unescaped text content for the
+ * block. If the type is 'list', the map will have a 'items' key which maps to
+ * list of unescaped list item strings. If the type is quote, the map will have
+ * a 'quotedBlocks' key which maps to the blocks contained within the quote.
+ *
+ * This mechanism encodes as little structure as possible in order to depend on
+ * the Soy autoescape mechanism for all of the content.
+ *
+ * @param content
+ */
+{template .WikiFormat private="true" autoescape="strict" kind="html"}
+  {let $blockquoteStyle kind="css"}
+    border-left: 1px solid #aaa;
+    margin: 10px 0;
+    padding: 0 10px;
+  {/let}
+
+  {foreach $block in $content}
+    {if $block.type == 'paragraph'}
+      <p>{$block.text}</p>
+    {elseif $block.type == 'quote'}
+      <blockquote style="{$blockquoteStyle}">
+        {call .WikiFormat}{param content: $block.quotedBlocks /}{/call}
+      </blockquote>
+    {elseif $block.type == 'pre'}
+      {call .Pre}{param content: $block.text /}{/call}
+    {elseif $block.type == 'list'}
+      <ul>
+        {foreach $item in $block.items}
+          <li>{$item}</li>
+        {/foreach}
+      </ul>
+    {/if}
+  {/foreach}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
index 0163732..0d19f3f 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
@@ -17,7 +17,6 @@
 {namespace com.google.gerrit.server.mail.template}
 
 /**
- * @param change
  * @param email
  * @param fromName
  * @param patchSet
@@ -25,8 +24,8 @@
  */
 {template .ReplacePatchSetHtml autoescape="strict" kind="html"}
   <p>
-    {$fromName} uploaded patch set #{$patchSet.patchSetId} to{sp}
-    <strong>{$change.subject}</strong>.
+    {$fromName} <strong>uploaded patch set #{$patchSet.patchSetId}</strong>{sp}
+    to this change.
   </p>
 
   {if $email.changeUrl}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RestoredHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RestoredHtml.soy
index 525c6d3..ea4f615 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RestoredHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RestoredHtml.soy
@@ -17,14 +17,12 @@
 {namespace com.google.gerrit.server.mail.template}
 
 /**
- * @param change
- * @param coverLetter
  * @param email
  * @param fromName
  */
 {template .RestoredHtml autoescape="strict" kind="html"}
   <p>
-    {$fromName} restored <strong>{$change.subject}</strong>.
+    {$fromName} <strong>restored</strong> this change.
   </p>
 
   {if $email.changeUrl}
@@ -32,8 +30,4 @@
       {call .ViewChangeButton data="all" /}
     </p>
   {/if}
-
-  {if $coverLetter}
-    {call .Pre}{param content: $coverLetter /}{/call}
-  {/if}
 {/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RevertedHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RevertedHtml.soy
index 9770f09..d6407e7 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RevertedHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RevertedHtml.soy
@@ -17,14 +17,12 @@
 {namespace com.google.gerrit.server.mail.template}
 
 /**
- * @param change
- * @param coverLetter
  * @param email
  * @param fromName
  */
 {template .RevertedHtml autoescape="strict" kind="html"}
   <p>
-    {$fromName} reverted the change: <strong>{$change.subject}</strong>.
+    {$fromName} <strong>reverted</strong> this change.
   </p>
 
   {if $email.changeUrl}
@@ -32,8 +30,4 @@
       {call .ViewChangeButton data="all" /}
     </p>
   {/if}
-
-  {if $coverLetter}
-    {call .Pre}{param content: $coverLetter /}{/call}
-  {/if}
 {/template}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/account/UniversalGroupBackendTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/account/UniversalGroupBackendTest.java
index 3cec25c..b9514e1 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/account/UniversalGroupBackendTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/account/UniversalGroupBackendTest.java
@@ -35,8 +35,7 @@
 import com.google.gerrit.reviewdb.client.AccountGroup.UUID;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gwtorm.client.KeyUtil;
-import com.google.gwtorm.server.StandardKeyEncoder;
+import com.google.gerrit.testutil.GerritBaseTests;
 
 import org.easymock.IAnswer;
 import org.junit.Before;
@@ -44,11 +43,7 @@
 
 import java.util.Set;
 
-public class UniversalGroupBackendTest {
-  static {
-    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
-  }
-
+public class UniversalGroupBackendTest extends GerritBaseTests {
   private static final AccountGroup.UUID OTHER_UUID =
       new AccountGroup.UUID("other");
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/change/WalkSorterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/change/WalkSorterTest.java
index 4f2166d..7f6bb5e 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/change/WalkSorterTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/change/WalkSorterTest.java
@@ -26,11 +26,10 @@
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.change.WalkSorter.PatchSetData;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.testutil.GerritBaseTests;
 import com.google.gerrit.testutil.InMemoryRepositoryManager;
 import com.google.gerrit.testutil.InMemoryRepositoryManager.Repo;
 import com.google.gerrit.testutil.TestChanges;
-import com.google.gwtorm.client.KeyUtil;
-import com.google.gwtorm.server.StandardKeyEncoder;
 
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
@@ -42,11 +41,7 @@
 import java.util.ArrayList;
 import java.util.List;
 
-public class WalkSorterTest {
-  static {
-    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
-  }
-
+public class WalkSorterTest extends GerritBaseTests {
   private Account.Id userId;
   private InMemoryRepositoryManager repoManager;
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
index 86fa0db..ddb4bf3 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
@@ -15,9 +15,11 @@
 package com.google.gerrit.server.git;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
 
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.util.HostPlatform;
 import com.google.gerrit.testutil.TempFileUtil;
 import com.google.gwtorm.client.KeyUtil;
 import com.google.gwtorm.server.StandardKeyEncoder;
@@ -34,6 +36,7 @@
 import org.junit.Test;
 
 import java.io.IOException;
+import java.nio.file.Files;
 import java.nio.file.Path;
 
 public class LocalDiskRepositoryManagerTest extends EasyMockSupport {
@@ -53,7 +56,6 @@
     cfg = new Config();
     cfg.setString("gerrit", null, "basePath", "git");
     repoManager = new LocalDiskRepositoryManager(site, cfg);
-    repoManager.start();
   }
 
   @Test(expected = IllegalStateException.class)
@@ -164,6 +166,20 @@
     repoManager.createRepository(new Project.NameKey("project\\rA"));
   }
 
+  @Test(expected = IllegalStateException.class)
+  public void testProjectRecreation() throws Exception {
+    repoManager.createRepository(new Project.NameKey("a"));
+    repoManager.createRepository(new Project.NameKey("a"));
+  }
+
+  @Test(expected = IllegalStateException.class)
+  public void testProjectRecreationAfterRestart() throws Exception {
+    repoManager.createRepository(new Project.NameKey("a"));
+    LocalDiskRepositoryManager newRepoManager =
+        new LocalDiskRepositoryManager(site, cfg);
+    newRepoManager.createRepository(new Project.NameKey("a"));
+  }
+
   @Test
   public void testOpenRepositoryCreatedDirectlyOnDisk() throws Exception {
     Project.NameKey projectA = new Project.NameKey("projectA");
@@ -174,6 +190,41 @@
     assertThat(repoManager.list()).containsExactly(projectA);
   }
 
+  @Test(expected = RepositoryCaseMismatchException.class)
+  public void testNameCaseMismatch() throws Exception {
+    assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
+    repoManager.createRepository(new Project.NameKey("a"));
+    repoManager.createRepository(new Project.NameKey("A"));
+  }
+
+  @Test(expected = RepositoryCaseMismatchException.class)
+  public void testNameCaseMismatchWithSymlink() throws Exception {
+    assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
+    Project.NameKey name = new Project.NameKey("a");
+    repoManager.createRepository(name);
+    createSymLink(name, "b.git");
+    repoManager.createRepository(new Project.NameKey("B"));
+  }
+
+  @Test(expected = RepositoryCaseMismatchException.class)
+  public void testNameCaseMismatchAfterRestart() throws Exception {
+    assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
+    Project.NameKey name = new Project.NameKey("a");
+    repoManager.createRepository(name);
+
+    LocalDiskRepositoryManager newRepoManager =
+        new LocalDiskRepositoryManager(site, cfg);
+    newRepoManager.createRepository(new Project.NameKey("A"));
+  }
+
+  private void createSymLink(Project.NameKey project, String link)
+      throws IOException {
+    Path base = repoManager.getBasePath(project);
+    Path projectDir = base.resolve(project.get() + ".git");
+    Path symlink = base.resolve(link);
+    Files.createSymbolicLink(symlink, projectDir);
+  }
+
   @Test(expected = RepositoryNotFoundException.class)
   public void testOpenRepositoryInvalidName() throws Exception {
     repoManager.openRepository(new Project.NameKey("project%?|<>A"));
@@ -197,28 +248,6 @@
         .containsExactly(projectA, projectB, projectC);
   }
 
-  @Test
-  public void testGetSetProjectDescription() throws Exception {
-    Project.NameKey projectA = new Project.NameKey("projectA");
-    try (Repository repo = repoManager.createRepository(projectA)) {
-      assertThat(repo).isNotNull();
-    }
-
-    assertThat(repoManager.getProjectDescription(projectA)).isNull();
-    repoManager.setProjectDescription(projectA, "projectA description");
-    assertThat(repoManager.getProjectDescription(projectA)).isEqualTo(
-        "projectA description");
-
-    repoManager.setProjectDescription(projectA, "");
-    assertThat(repoManager.getProjectDescription(projectA)).isNull();
-  }
-
-  @Test(expected = RepositoryNotFoundException.class)
-  public void testGetProjectDescriptionFromUnexistingRepository()
-      throws Exception {
-    repoManager.getProjectDescription(new Project.NameKey("projectA"));
-  }
-
   private void createRepository(Path directory, String projectName)
       throws IOException {
     String n = projectName + Constants.DOT_GIT_EXT;
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
index b26a228..a78f6c2 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
@@ -23,9 +23,8 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.RepositoryConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.testutil.GerritBaseTests;
 import com.google.gerrit.testutil.TempFileUtil;
-import com.google.gwtorm.client.KeyUtil;
-import com.google.gwtorm.server.StandardKeyEncoder;
 
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
@@ -45,12 +44,7 @@
 import java.util.Arrays;
 import java.util.SortedSet;
 
-public class MultiBaseLocalDiskRepositoryManagerTest {
-
-  static {
-    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
-  }
-
+public class MultiBaseLocalDiskRepositoryManagerTest extends GerritBaseTests {
   private Config cfg;
   private SitePaths site;
   private MultiBaseLocalDiskRepositoryManager repoManager;
@@ -76,7 +70,8 @@
 
   @Test
   public void testDefaultRepositoryLocation()
-      throws RepositoryCaseMismatchException, RepositoryNotFoundException {
+      throws RepositoryCaseMismatchException, RepositoryNotFoundException,
+      IOException {
     Project.NameKey someProjectKey = new Project.NameKey("someProject");
     Repository repo = repoManager.createRepository(someProjectKey);
     assertThat(repo.getDirectory()).isNotNull();
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeFieldTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeFieldTest.java
index 7871437..693abfb 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -28,8 +28,6 @@
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.testutil.GerritBaseTests;
 import com.google.gerrit.testutil.TestTimeUtil;
-import com.google.gwtorm.client.KeyUtil;
-import com.google.gwtorm.server.StandardKeyEncoder;
 
 import org.junit.After;
 import org.junit.Before;
@@ -40,10 +38,6 @@
 import java.util.concurrent.TimeUnit;
 
 public class ChangeFieldTest extends GerritBaseTests {
-  static {
-    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
-  }
-
   @Before
   public void setUp() {
     TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java
index 545fd08..e59067a 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java
@@ -29,8 +29,8 @@
           FakeQueryBuilder.class),
         new ChangeQueryBuilder.Arguments(null, null, null, null, null, null,
           null, null, null, null, null, null, null, null, null, null, null,
-          null, null, null, indexes, null, null, null, null, null, null, null,
-          null));
+          null, null, null, null, indexes, null, null, null, null, null, null,
+          null, null));
   }
 
   @Operator
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java
new file mode 100644
index 0000000..913ce93
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java
@@ -0,0 +1,359 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.server.index.change.StalenessChecker.refsAreStale;
+import static com.google.gerrit.testutil.TestChanges.newChange;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.change.StalenessChecker.RefState;
+import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
+import com.google.gerrit.server.notedb.NoteDbChangeState;
+import com.google.gerrit.testutil.GerritBaseTests;
+import com.google.gerrit.testutil.InMemoryRepositoryManager;
+import com.google.gwtorm.protobuf.CodecFactory;
+import com.google.gwtorm.protobuf.ProtobufCodec;
+
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.stream.Stream;
+
+public class StalenessCheckerTest extends GerritBaseTests {
+  private static final String SHA1 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+  private static final String SHA2 = "badc0feebadc0feebadc0feebadc0feebadc0fee";
+
+  private static final Project.NameKey P1 = new Project.NameKey("project1");
+  private static final Project.NameKey P2 = new Project.NameKey("project2");
+
+  private static final Change.Id C = new Change.Id(1234);
+
+  private static final ProtobufCodec<Change> CHANGE_CODEC =
+      CodecFactory.encoder(Change.class);
+
+  private GitRepositoryManager repoManager;
+  private Repository r1;
+  private Repository r2;
+  private TestRepository<Repository> tr1;
+  private TestRepository<Repository> tr2;
+
+  @Before
+  public void setUp() throws Exception {
+    repoManager = new InMemoryRepositoryManager();
+    r1 = repoManager.createRepository(P1);
+    tr1 = new TestRepository<>(r1);
+    r2 = repoManager.createRepository(P2);
+    tr2 = new TestRepository<>(r2);
+  }
+
+  @Test
+  public void parseStates() {
+    assertInvalidState(null);
+    assertInvalidState("");
+    assertInvalidState("project1:refs/heads/foo");
+    assertInvalidState("project1:refs/heads/foo:notasha");
+    assertInvalidState("project1:refs/heads/foo:");
+
+    assertThat(
+            StalenessChecker.parseStates(
+                byteArrays(
+                    P1 + ":refs/heads/foo:" + SHA1,
+                    P1 + ":refs/heads/bar:" + SHA2,
+                    P2 + ":refs/heads/baz:" + SHA1)))
+        .isEqualTo(
+            ImmutableSetMultimap.of(
+                P1, RefState.create("refs/heads/foo", SHA1),
+                P1, RefState.create("refs/heads/bar", SHA2),
+                P2, RefState.create("refs/heads/baz", SHA1)));
+  }
+
+  private static void assertInvalidState(String state) {
+    try {
+      StalenessChecker.parseStates(byteArrays(state));
+      assert_().fail("expected IllegalArgumentException");
+    } catch (IllegalArgumentException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void refStateToByteArray() {
+    assertThat(
+            new String(
+                RefState.create("refs/heads/foo", ObjectId.fromString(SHA1))
+                    .toByteArray(P1),
+                UTF_8))
+        .isEqualTo(P1 + ":refs/heads/foo:" + SHA1);
+    assertThat(
+            new String(
+                RefState.create("refs/heads/foo", (ObjectId) null)
+                    .toByteArray(P1),
+                UTF_8))
+        .isEqualTo(P1 + ":refs/heads/foo:" + ObjectId.zeroId().name());
+  }
+
+  @Test
+  public void parsePatterns() {
+    assertInvalidPattern(null);
+    assertInvalidPattern("");
+    assertInvalidPattern("project:");
+    assertInvalidPattern("project:refs/heads/foo");
+    assertInvalidPattern("project:refs/he*ds/bar");
+    assertInvalidPattern("project:refs/(he)*ds/bar");
+    assertInvalidPattern("project:invalidrefname");
+
+    ListMultimap<Project.NameKey, RefStatePattern> r =
+        StalenessChecker.parsePatterns(
+            byteArrays(
+                P1 + ":refs/heads/*",
+                P2 + ":refs/heads/foo/*/bar",
+                P2 + ":refs/heads/foo/*-baz/*/quux"));
+
+    assertThat(r.keySet()).containsExactly(P1, P2);
+    RefStatePattern p = r.get(P1).get(0);
+    assertThat(p.pattern()).isEqualTo("refs/heads/*");
+    assertThat(p.prefix()).isEqualTo("refs/heads/");
+    assertThat(p.regex().pattern()).isEqualTo("^\\Qrefs/heads/\\E.*\\Q\\E$");
+    assertThat(p.match("refs/heads/foo")).isTrue();
+    assertThat(p.match("xrefs/heads/foo")).isFalse();
+    assertThat(p.match("refs/tags/foo")).isFalse();
+
+    p = r.get(P2).get(0);
+    assertThat(p.pattern()).isEqualTo("refs/heads/foo/*/bar");
+    assertThat(p.prefix()).isEqualTo("refs/heads/foo/");
+    assertThat(p.regex().pattern())
+        .isEqualTo("^\\Qrefs/heads/foo/\\E.*\\Q/bar\\E$");
+    assertThat(p.match("refs/heads/foo//bar")).isTrue();
+    assertThat(p.match("refs/heads/foo/x/bar")).isTrue();
+    assertThat(p.match("refs/heads/foo/x/y/bar")).isTrue();
+    assertThat(p.match("refs/heads/foo/x/baz")).isFalse();
+
+    p = r.get(P2).get(1);
+    assertThat(p.pattern()).isEqualTo("refs/heads/foo/*-baz/*/quux");
+    assertThat(p.prefix()).isEqualTo("refs/heads/foo/");
+    assertThat(p.regex().pattern())
+        .isEqualTo("^\\Qrefs/heads/foo/\\E.*\\Q-baz/\\E.*\\Q/quux\\E$");
+    assertThat(p.match("refs/heads/foo/-baz//quux")).isTrue();
+    assertThat(p.match("refs/heads/foo/x-baz/x/quux")).isTrue();
+    assertThat(p.match("refs/heads/foo/x/y-baz/x/y/quux")).isTrue();
+    assertThat(p.match("refs/heads/foo/x-baz/x/y")).isFalse();
+  }
+
+  @Test
+  public void refStatePatternToByteArray() {
+    assertThat(
+            new String(RefStatePattern.create("refs/*").toByteArray(P1), UTF_8))
+        .isEqualTo(P1 + ":refs/*");
+  }
+
+  private static void assertInvalidPattern(String state) {
+    try {
+      StalenessChecker.parsePatterns(byteArrays(state));
+      assert_().fail("expected IllegalArgumentException");
+    } catch (IllegalArgumentException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void isStaleRefStatesOnly() throws Exception {
+    String ref1 = "refs/heads/foo";
+    ObjectId id1 = tr1.update(ref1, tr1.commit().message("commit 1"));
+    String ref2 = "refs/heads/bar";
+    ObjectId id2 = tr2.update(ref2, tr2.commit().message("commit 2"));
+
+    // Not stale.
+    assertThat(
+            refsAreStale(
+                repoManager, C,
+                ImmutableSetMultimap.of(
+                    P1, RefState.create(ref1, id1.name()),
+                    P2, RefState.create(ref2, id2.name())),
+                ImmutableMultimap.of()))
+        .isFalse();
+
+    // Wrong ref value.
+    assertThat(
+            refsAreStale(
+                repoManager, C,
+                ImmutableSetMultimap.of(
+                    P1, RefState.create(ref1, SHA1),
+                    P2, RefState.create(ref2, id2.name())),
+                ImmutableMultimap.of()))
+        .isTrue();
+
+    // Swapped repos.
+    assertThat(
+            refsAreStale(
+                repoManager, C,
+                ImmutableSetMultimap.of(
+                    P1, RefState.create(ref1, id2.name()),
+                    P2, RefState.create(ref2, id1.name())),
+                ImmutableMultimap.of()))
+        .isTrue();
+
+    // Two refs in same repo, not stale.
+    String ref3 = "refs/heads/baz";
+    ObjectId id3 = tr1.update(ref3, tr1.commit().message("commit 3"));
+    tr1.update(ref3, id3);
+    assertThat(
+            refsAreStale(
+                repoManager, C,
+                ImmutableSetMultimap.of(
+                    P1, RefState.create(ref1, id1.name()),
+                    P1, RefState.create(ref3, id3.name())),
+                ImmutableMultimap.of()))
+        .isFalse();
+
+    // Ignore ref not mentioned.
+    assertThat(
+            refsAreStale(
+                repoManager, C,
+                ImmutableSetMultimap.of(
+                    P1, RefState.create(ref1, id1.name())),
+                ImmutableMultimap.of()))
+        .isFalse();
+
+    // One ref wrong.
+    assertThat(
+            refsAreStale(
+                repoManager, C,
+                ImmutableSetMultimap.of(
+                    P1, RefState.create(ref1, id1.name()),
+                    P1, RefState.create(ref3, SHA1)),
+                ImmutableMultimap.of()))
+        .isTrue();
+  }
+
+  @Test
+  public void isStaleWithRefStatePatterns() throws Exception {
+    String ref1 = "refs/heads/foo";
+    ObjectId id1 = tr1.update(ref1, tr1.commit().message("commit 1"));
+
+    // ref1 is only ref matching pattern.
+    assertThat(
+            refsAreStale(
+                repoManager, C,
+                ImmutableSetMultimap.of(
+                    P1, RefState.create(ref1, id1.name())),
+                ImmutableMultimap.of(
+                    P1, RefStatePattern.create("refs/heads/*"))))
+        .isFalse();
+
+    // Now ref2 matches pattern, so stale unless ref2 is present in state map.
+    String ref2 = "refs/heads/bar";
+    ObjectId id2 = tr1.update(ref2, tr1.commit().message("commit 2"));
+    assertThat(
+            refsAreStale(
+                repoManager, C,
+                ImmutableSetMultimap.of(
+                    P1, RefState.create(ref1, id1.name())),
+                ImmutableMultimap.of(
+                    P1, RefStatePattern.create("refs/heads/*"))))
+        .isTrue();
+    assertThat(
+            refsAreStale(
+                repoManager, C,
+                ImmutableSetMultimap.of(
+                    P1, RefState.create(ref1, id1.name()),
+                    P1, RefState.create(ref2, id2.name())),
+                ImmutableMultimap.of(
+                    P1, RefStatePattern.create("refs/heads/*"))))
+        .isFalse();
+  }
+
+  @Test
+  public void isStaleWithNonPrefixPattern() throws Exception {
+    String ref1 = "refs/heads/foo";
+    ObjectId id1 = tr1.update(ref1, tr1.commit().message("commit 1"));
+    tr1.update("refs/heads/bar", tr1.commit().message("commit 2"));
+
+    // ref1 is only ref matching pattern.
+    assertThat(
+            refsAreStale(
+                repoManager, C,
+                ImmutableSetMultimap.of(
+                    P1, RefState.create(ref1, id1.name())),
+                ImmutableMultimap.of(
+                    P1, RefStatePattern.create("refs/*/foo"))))
+        .isFalse();
+
+    // Now ref2 matches pattern, so stale unless ref2 is present in state map.
+    String ref3 = "refs/other/foo";
+    ObjectId id3 = tr1.update(ref3, tr1.commit().message("commit 3"));
+    assertThat(
+            refsAreStale(
+                repoManager, C,
+                ImmutableSetMultimap.of(
+                    P1, RefState.create(ref1, id1.name())),
+                ImmutableMultimap.of(
+                    P1, RefStatePattern.create("refs/*/foo"))))
+        .isTrue();
+    assertThat(
+            refsAreStale(
+                repoManager, C,
+                ImmutableSetMultimap.of(
+                    P1, RefState.create(ref1, id1.name()),
+                    P1, RefState.create(ref3, id3.name())),
+                ImmutableMultimap.of(
+                    P1, RefStatePattern.create("refs/*/foo"))))
+        .isFalse();
+  }
+
+  @Test
+  public void reviewDbChangeIsStale() throws Exception {
+    Change indexChange = newChange(P1, new Account.Id(1));
+    indexChange.setNoteDbState(SHA1);
+
+    assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, null))
+        .isFalse();
+
+    Change noteDbPrimary = clone(indexChange);
+    noteDbPrimary.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
+    assertThat(
+            StalenessChecker.reviewDbChangeIsStale(indexChange, noteDbPrimary))
+        .isFalse();
+
+    assertThat(
+            StalenessChecker.reviewDbChangeIsStale(
+                indexChange, clone(indexChange)))
+        .isFalse();
+
+    // Can't easily change row version to check true case.
+  }
+
+  private static Iterable<byte[]> byteArrays(String... strs) {
+    return Stream.of(strs).map(s -> s != null ? s.getBytes(UTF_8) : null)
+        .collect(toList());
+  }
+
+  private static Change clone(Change change) {
+    return CHANGE_CODEC.decode(CHANGE_CODEC.encodeToByteArray(change));
+  }
+
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/MetadataParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/MetadataParserTest.java
new file mode 100644
index 0000000..65914c8
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/MetadataParserTest.java
@@ -0,0 +1,121 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.receive;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.mail.MetadataName.toFooterWithDelimiter;
+import static com.google.gerrit.server.mail.MetadataName.toHeaderWithDelimiter;
+
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.MetadataName;
+
+import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
+import org.junit.Test;
+
+public class MetadataParserTest {
+  @Test
+  public void testParseMetadataFromHeader() {
+    // This tests if the metadata parser is able to parse metadata from the
+    // email headers of the message.
+    MailMessage.Builder b = MailMessage.builder();
+    b.id("");
+    b.dateReceived(new DateTime());
+    b.subject("");
+
+    b.addAdditionalHeader(
+        toHeaderWithDelimiter(MetadataName.CHANGE_ID) + "cid");
+    b.addAdditionalHeader(toHeaderWithDelimiter(MetadataName.PATCH_SET) + "1");
+    b.addAdditionalHeader(
+        toHeaderWithDelimiter(MetadataName.MESSAGE_TYPE) +"comment");
+    b.addAdditionalHeader(toHeaderWithDelimiter(MetadataName.TIMESTAMP) +
+        "Tue, 25 Oct 2016 02:11:35 -0700");
+
+    Address author = new Address("Diffy", "test@gerritcodereview.com");
+    b.from(author);
+
+    MailMetadata meta = MetadataParser.parse(b.build());
+    assertThat(meta.author).isEqualTo(author.getEmail());
+    assertThat(meta.changeId).isEqualTo("cid");
+    assertThat(meta.patchSet).isEqualTo(1);
+    assertThat(meta.messageType).isEqualTo("comment");
+    assertThat(meta.timestamp.getTime()).isEqualTo(
+        new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC).getMillis());
+  }
+
+  @Test
+  public void testParseMetadataFromText() {
+    // This tests if the metadata parser is able to parse metadata from the
+    // the text body of the message.
+    MailMessage.Builder b = MailMessage.builder();
+    b.id("");
+    b.dateReceived(new DateTime());
+    b.subject("");
+
+    StringBuilder stringBuilder = new StringBuilder();
+    stringBuilder.append(
+        toFooterWithDelimiter(MetadataName.CHANGE_ID) + "cid" + "\n");
+    stringBuilder.append(
+        toFooterWithDelimiter(MetadataName.PATCH_SET) + "1" + "\n");
+    stringBuilder.append(
+        toFooterWithDelimiter(MetadataName.MESSAGE_TYPE) + "comment" + "\n");
+    stringBuilder.append(toFooterWithDelimiter(MetadataName.TIMESTAMP) +
+        "Tue, 25 Oct 2016 02:11:35 -0700" + "\n");
+    b.textContent(stringBuilder.toString());
+
+    Address author = new Address("Diffy", "test@gerritcodereview.com");
+    b.from(author);
+
+    MailMetadata meta = MetadataParser.parse(b.build());
+    assertThat(meta.author).isEqualTo(author.getEmail());
+    assertThat(meta.changeId).isEqualTo("cid");
+    assertThat(meta.patchSet).isEqualTo(1);
+    assertThat(meta.messageType).isEqualTo("comment");
+    assertThat(meta.timestamp.getTime()).isEqualTo(
+        new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC).getMillis());
+  }
+
+  @Test
+  public void testParseMetadataFromHTML() {
+    // This tests if the metadata parser is able to parse metadata from the
+    // the HTML body of the message.
+    MailMessage.Builder b = MailMessage.builder();
+    b.id("");
+    b.dateReceived(new DateTime());
+    b.subject("");
+
+    StringBuilder stringBuilder = new StringBuilder();
+    stringBuilder.append("<p>" +
+        toFooterWithDelimiter(MetadataName.CHANGE_ID) + "cid" + "</p>");
+    stringBuilder.append("<p>" + toFooterWithDelimiter(MetadataName.PATCH_SET) +
+        "1" + "</p>");
+    stringBuilder.append("<p>" +
+        toFooterWithDelimiter(MetadataName.MESSAGE_TYPE) + "comment" + "</p>");
+    stringBuilder.append("<p>" + toFooterWithDelimiter(MetadataName.TIMESTAMP) +
+        "Tue, 25 Oct 2016 02:11:35 -0700" + "</p>");
+    b.htmlContent(stringBuilder.toString());
+
+    Address author = new Address("Diffy", "test@gerritcodereview.com");
+    b.from(author);
+
+    MailMetadata meta = MetadataParser.parse(b.build());
+    assertThat(meta.author).isEqualTo(author.getEmail());
+    assertThat(meta.changeId).isEqualTo("cid");
+    assertThat(meta.patchSet).isEqualTo(1);
+    assertThat(meta.messageType).isEqualTo("comment");
+    assertThat(meta.timestamp.getTime()).isEqualTo(
+        new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC).getMillis());
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/CommentFormatterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/CommentFormatterTest.java
new file mode 100644
index 0000000..2944038
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/CommentFormatterTest.java
@@ -0,0 +1,454 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.LIST;
+import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.PARAGRAPH;
+import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.PRE_FORMATTED;
+import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.QUOTE;
+
+import org.junit.Test;
+
+import java.util.List;
+
+public class CommentFormatterTest {
+  private void assertBlock(List<CommentFormatter.Block> list, int index,
+      CommentFormatter.BlockType type, String text) {
+    CommentFormatter.Block block = list.get(index);
+    assertThat(block.type).isEqualTo(type);
+    assertThat(block.text).isEqualTo(text);
+    assertThat(block.items).isNull();
+    assertThat(block.quotedBlocks).isNull();
+  }
+
+  private void assertListBlock(List<CommentFormatter.Block> list, int index,
+      int itemIndex, String text) {
+    CommentFormatter.Block block = list.get(index);
+    assertThat(block.type).isEqualTo(LIST);
+    assertThat(block.items.get(itemIndex)).isEqualTo(text);
+    assertThat(block.text).isNull();
+    assertThat(block.quotedBlocks).isNull();
+  }
+
+  private void assertQuoteBlock(List<CommentFormatter.Block> list, int index,
+      int size) {
+    CommentFormatter.Block block = list.get(index);
+    assertThat(block.type).isEqualTo(QUOTE);
+    assertThat(block.items).isNull();
+    assertThat(block.text).isNull();
+    assertThat(block.quotedBlocks).hasSize(size);
+  }
+
+  @Test
+  public void testParseNullAsEmpty() {
+    assertThat(CommentFormatter.parse(null)).isEmpty();
+  }
+
+  @Test
+  public void testParseEmpty() {
+    assertThat(CommentFormatter.parse("")).isEmpty();
+  }
+
+  @Test
+  public void testParseSimple() {
+    String comment = "Para1";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PARAGRAPH, comment);
+  }
+
+  @Test
+  public void testParseMultilinePara() {
+    String comment = "Para 1\nStill para 1";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PARAGRAPH, comment);
+  }
+
+  @Test
+  public void testParseParaBreak() {
+    String comment = "Para 1\n\nPara 2\n\nPara 3";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "Para 1");
+    assertBlock(result, 1, PARAGRAPH, "Para 2");
+    assertBlock(result, 2, PARAGRAPH, "Para 3");
+  }
+
+  @Test
+  public void testParseQuote() {
+    String comment = "> Quote text";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertQuoteBlock(result, 0, 1);
+    assertBlock(result.get(0).quotedBlocks, 0, PARAGRAPH, "Quote text");
+  }
+
+  @Test
+  public void testParseExcludesEmpty() {
+    String comment = "Para 1\n\n\n\nPara 2";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "Para 1");
+    assertBlock(result, 1, PARAGRAPH, "Para 2");
+  }
+
+  @Test
+  public void testParseQuoteLeadSpace() {
+    String comment = " > Quote text";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertQuoteBlock(result, 0, 1);
+    assertBlock(result.get(0).quotedBlocks, 0, PARAGRAPH, "Quote text");
+  }
+
+  @Test
+  public void testParseMultiLineQuote() {
+    String comment = "> Quote line 1\n> Quote line 2\n > Quote line 3\n";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertQuoteBlock(result, 0, 1);
+    assertBlock(result.get(0).quotedBlocks, 0, PARAGRAPH,
+        "Quote line 1\nQuote line 2\nQuote line 3\n");
+  }
+
+  @Test
+  public void testParsePre() {
+    String comment = "    Four space indent.";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PRE_FORMATTED, comment);
+  }
+
+  @Test
+  public void testParseOneSpacePre() {
+    String comment = " One space indent.\n Another line.";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PRE_FORMATTED, comment);
+  }
+
+  @Test
+  public void testParseTabPre() {
+    String comment = "\tOne tab indent.\n\tAnother line.\n  Yet another!";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PRE_FORMATTED, comment);
+  }
+
+  @Test
+  public void testParseIntermediateLeadingWhitespacePre() {
+    String comment = "No indent.\n\tNonzero indent.\nNo indent again.";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PRE_FORMATTED, comment);
+  }
+
+  @Test
+  public void testParseStarList() {
+    String comment = "* Item 1\n* Item 2\n* Item 3";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertListBlock(result, 0, 0, "Item 1");
+    assertListBlock(result, 0, 1, "Item 2");
+    assertListBlock(result, 0, 2, "Item 3");
+  }
+
+  @Test
+  public void testParseDashList() {
+    String comment = "- Item 1\n- Item 2\n- Item 3";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertListBlock(result, 0, 0, "Item 1");
+    assertListBlock(result, 0, 1, "Item 2");
+    assertListBlock(result, 0, 2, "Item 3");
+  }
+
+  @Test
+  public void testParseMixedList() {
+    String comment = "- Item 1\n* Item 2\n- Item 3\n* Item 4";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertListBlock(result, 0, 0, "Item 1");
+    assertListBlock(result, 0, 1, "Item 2");
+    assertListBlock(result, 0, 2, "Item 3");
+    assertListBlock(result, 0, 3, "Item 4");
+  }
+
+  @Test
+  public void testParseMixedBlockTypes() {
+    String comment = "Paragraph\nacross\na\nfew\nlines."
+        + "\n\n"
+        + "> Quote\n> across\n> not many lines."
+        + "\n\n"
+        + "Another paragraph"
+        + "\n\n"
+        + "* Series\n* of\n* list\n* items"
+        + "\n\n"
+        + "Yet another paragraph"
+        + "\n\n"
+        + "\tPreformatted text."
+        + "\n\n"
+        + "Parting words.";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(7);
+    assertBlock(result, 0, PARAGRAPH, "Paragraph\nacross\na\nfew\nlines.");
+    assertQuoteBlock(result, 1, 1);
+    assertBlock(result.get(1).quotedBlocks, 0, PARAGRAPH,
+        "Quote\nacross\nnot many lines.");
+    assertBlock(result, 2, PARAGRAPH, "Another paragraph");
+    assertListBlock(result, 3, 0, "Series");
+    assertListBlock(result, 3, 1, "of");
+    assertListBlock(result, 3, 2, "list");
+    assertListBlock(result, 3, 3, "items");
+    assertBlock(result, 4, PARAGRAPH, "Yet another paragraph");
+    assertBlock(result, 5, PRE_FORMATTED, "\tPreformatted text.");
+    assertBlock(result, 6, PARAGRAPH, "Parting words.");
+  }
+
+  @Test
+  public void testBulletList1() {
+    String comment = "A\n\n* line 1\n* 2nd line";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertListBlock(result, 1, 0, "line 1");
+    assertListBlock(result, 1, 1, "2nd line");
+  }
+
+  @Test
+  public void testBulletList2() {
+    String comment = "A\n\n* line 1\n* 2nd line\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertListBlock(result, 1, 0, "line 1");
+    assertListBlock(result, 1, 1, "2nd line");
+    assertBlock(result, 2, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void testBulletList3() {
+    String comment = "* line 1\n* 2nd line\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertListBlock(result, 0, 0, "line 1");
+    assertListBlock(result, 0, 1, "2nd line");
+    assertBlock(result, 1, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void testBulletList4() {
+    String comment = "To see this bug, you have to:\n" //
+        + "* Be on IMAP or EAS (not on POP)\n"//
+        + "* Be very unlucky\n";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "To see this bug, you have to:");
+    assertListBlock(result, 1, 0, "Be on IMAP or EAS (not on POP)");
+    assertListBlock(result, 1, 1, "Be very unlucky");
+  }
+
+  @Test
+  public void testBulletList5() {
+    String comment = "To see this bug,\n" //
+        + "you have to:\n" //
+        + "* Be on IMAP or EAS (not on POP)\n"//
+        + "* Be very unlucky\n";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "To see this bug, you have to:");
+    assertListBlock(result, 1, 0, "Be on IMAP or EAS (not on POP)");
+    assertListBlock(result, 1, 1, "Be very unlucky");
+  }
+
+  @Test
+  public void testDashList1() {
+    String comment = "A\n\n- line 1\n- 2nd line";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertListBlock(result, 1, 0, "line 1");
+    assertListBlock(result, 1, 1, "2nd line");
+  }
+
+  @Test
+  public void testDashList2() {
+    String comment = "A\n\n- line 1\n- 2nd line\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertListBlock(result, 1, 0, "line 1");
+    assertListBlock(result, 1, 1, "2nd line");
+    assertBlock(result, 2, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void testDashList3() {
+    String comment = "- line 1\n- 2nd line\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertListBlock(result, 0, 0, "line 1");
+    assertListBlock(result, 0, 1, "2nd line");
+    assertBlock(result, 1, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void testPreformat1() {
+    String comment = "A\n\n  This is pre\n  formatted";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertBlock(result, 1, PRE_FORMATTED, "  This is pre\n  formatted");
+  }
+
+  @Test
+  public void testPreformat2() {
+    String comment = "A\n\n  This is pre\n  formatted\n\nbut this is not";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertBlock(result, 1, PRE_FORMATTED, "  This is pre\n  formatted");
+    assertBlock(result, 2, PARAGRAPH, "but this is not");
+  }
+
+  @Test
+  public void testPreformat3() {
+    String comment = "A\n\n  Q\n    <R>\n  S\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertBlock(result, 1, PRE_FORMATTED, "  Q\n    <R>\n  S");
+    assertBlock(result, 2, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void testPreformat4() {
+    String comment = "  Q\n    <R>\n  S\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PRE_FORMATTED, "  Q\n    <R>\n  S");
+    assertBlock(result, 1, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void testQuote1() {
+    String comment = "> I'm happy\n > with quotes!\n\nSee above.";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertQuoteBlock(result, 0, 1);
+    assertBlock(result.get(0).quotedBlocks, 0, PARAGRAPH,
+        "I'm happy\nwith quotes!");
+    assertBlock(result, 1, PARAGRAPH, "See above.");
+  }
+
+  @Test
+  public void testQuote2() {
+    String comment = "See this said:\n\n > a quoted\n > string block\n\nOK?";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "See this said:");
+    assertQuoteBlock(result, 1, 1);
+    assertBlock(result.get(1).quotedBlocks, 0, PARAGRAPH,
+        "a quoted\nstring block");
+    assertBlock(result, 2, PARAGRAPH, "OK?");
+  }
+
+  @Test
+  public void testNestedQuotes1() {
+    String comment = " > > prior\n > \n > next\n";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertQuoteBlock(result, 0, 2);
+    assertQuoteBlock(result.get(0).quotedBlocks, 0, 1);
+    assertBlock(result.get(0).quotedBlocks.get(0).quotedBlocks, 0, PARAGRAPH,
+        "prior");
+    assertBlock(result.get(0).quotedBlocks, 1, PARAGRAPH, "next\n");
+  }
+
+  @Test
+  public void largeMixedQuote() {
+    String comment =
+        "> > Paragraph 1.\n" +
+        "> > \n" +
+        "> > > Paragraph 2.\n" +
+        "> > \n" +
+        "> > Paragraph 3.\n" +
+        "> > \n" +
+        "> >    pre line 1;\n" +
+        "> >    pre line 2;\n" +
+        "> > \n" +
+        "> > Paragraph 4.\n" +
+        "> > \n" +
+        "> > * List item 1.\n" +
+        "> > * List item 2.\n" +
+        "> > \n" +
+        "> > Paragraph 5.\n" +
+        "> \n" +
+        "> Paragraph 6.\n" +
+        "\n" +
+        "Paragraph 7.\n";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertQuoteBlock(result, 0, 2);
+
+    assertQuoteBlock(result.get(0).quotedBlocks, 0, 7);
+    List<CommentFormatter.Block> bigQuote =
+        result.get(0).quotedBlocks.get(0).quotedBlocks;
+    assertBlock(bigQuote, 0, PARAGRAPH, "Paragraph 1.");
+    assertQuoteBlock(bigQuote, 1, 1);
+    assertBlock(bigQuote.get(1).quotedBlocks, 0, PARAGRAPH, "Paragraph 2.");
+    assertBlock(bigQuote, 2, PARAGRAPH, "Paragraph 3.");
+    assertBlock(bigQuote, 3, PRE_FORMATTED, "   pre line 1;\n   pre line 2;");
+    assertBlock(bigQuote, 4, PARAGRAPH, "Paragraph 4.");
+    assertListBlock(bigQuote, 5, 0, "List item 1.");
+    assertListBlock(bigQuote, 5, 1, "List item 2.");
+    assertBlock(bigQuote, 6, PARAGRAPH, "Paragraph 5.");
+    assertBlock(result.get(0).quotedBlocks, 1, PARAGRAPH, "Paragraph 6.");
+    assertBlock(result, 1, PARAGRAPH, "Paragraph 7.\n");
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index 0a98c40..3e3abb3 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -59,9 +59,7 @@
 import com.google.gerrit.testutil.TestChanges;
 import com.google.gerrit.testutil.TestNotesMigration;
 import com.google.gerrit.testutil.TestTimeUtil;
-import com.google.gwtorm.client.KeyUtil;
 import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.StandardKeyEncoder;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
@@ -140,7 +138,6 @@
   @Before
   public void setUp() throws Exception {
     setTimeForTesting();
-    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
 
     serverIdent = new PersonIdent(
         "Gerrit Server", "noreply@gerrit.com", TimeUtil.nowTs(), TZ);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeBundleTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeBundleTest.java
index faa3105..01682e6 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeBundleTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeBundleTest.java
@@ -40,12 +40,11 @@
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
+import com.google.gerrit.testutil.GerritBaseTests;
 import com.google.gerrit.testutil.TestChanges;
 import com.google.gerrit.testutil.TestTimeUtil;
-import com.google.gwtorm.client.KeyUtil;
 import com.google.gwtorm.protobuf.CodecFactory;
 import com.google.gwtorm.protobuf.ProtobufCodec;
-import com.google.gwtorm.server.StandardKeyEncoder;
 
 import org.joda.time.DateTime;
 import org.joda.time.DateTimeZone;
@@ -60,11 +59,7 @@
 import java.util.List;
 import java.util.TimeZone;
 
-public class ChangeBundleTest {
-  static {
-    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
-  }
-
+public class ChangeBundleTest extends GerritBaseTests {
   private static final ProtobufCodec<Change> CHANGE_CODEC =
       CodecFactory.encoder(Change.class);
   private static final ProtobufCodec<ChangeMessage> CHANGE_MESSAGE_CODEC =
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java
index e3613e3..b0bfe57 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java
@@ -27,9 +27,8 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.notedb.NoteDbChangeState.Delta;
+import com.google.gerrit.testutil.GerritBaseTests;
 import com.google.gerrit.testutil.TestChanges;
-import com.google.gwtorm.client.KeyUtil;
-import com.google.gwtorm.server.StandardKeyEncoder;
 
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
@@ -37,11 +36,7 @@
 import java.util.Optional;
 
 /** Unit tests for {@link NoteDbChangeState}. */
-public class NoteDbChangeStateTest {
-  static {
-    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
-  }
-
+public class NoteDbChangeStateTest extends GerritBaseTests {
   ObjectId SHA1 =
       ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
   ObjectId SHA2 =
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
index 596ed59..316b984 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
@@ -57,6 +57,7 @@
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
@@ -241,6 +242,7 @@
   @Inject private SchemaCreator schemaCreator;
   @Inject private InMemoryDatabase schemaFactory;
   @Inject private ThreadLocalRequestContext requestContext;
+  @Inject private Provider<InternalChangeQuery> queryProvider;
 
   @Before
   public void setUp() throws Exception {
@@ -927,7 +929,7 @@
 
     return new ProjectControl(Collections.<AccountGroup.UUID> emptySet(),
         Collections.<AccountGroup.UUID> emptySet(), projectCache,
-        sectionSorter, null, changeControlFactory, null, null,
+        sectionSorter, null, changeControlFactory, null, queryProvider, null,
         canonicalWebUrl, new MockUser(name, memberOf), newProjectState(local));
   }
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index 8093bbb..037d1da 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -23,19 +23,22 @@
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.accounts.Accounts.QueryRequest;
 import com.google.gerrit.extensions.client.ListAccountsOption;
+import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
@@ -57,7 +60,9 @@
 import org.junit.Test;
 import org.junit.rules.TestName;
 
+import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
 
@@ -92,9 +97,6 @@
   protected InMemoryDatabase schemaFactory;
 
   @Inject
-  protected InternalChangeQuery internalChangeQuery;
-
-  @Inject
   protected SchemaCreator schemaCreator;
 
   @Inject
@@ -103,6 +105,12 @@
   @Inject
   protected OneOffRequestContext oneOffRequestContext;
 
+  @Inject
+  protected InternalAccountQuery internalAccountQuery;
+
+  @Inject
+  protected AllProjectsName allProjects;
+
   protected LifecycleManager lifecycle;
   protected ReviewDb db;
   protected AccountInfo currentUserInfo;
@@ -275,6 +283,29 @@
   }
 
   @Test
+  public void byWatchedProject() throws Exception {
+    Project.NameKey p = createProject(name("p"));
+    Project.NameKey p2 = createProject(name("p2"));
+    AccountInfo user1 = newAccountWithFullName("jdoe", "John Doe");
+    AccountInfo user2 = newAccountWithFullName("jroe", "Jane Roe");
+    AccountInfo user3 = newAccountWithFullName("user3", "Mr Selfish");
+
+    assertThat(internalAccountQuery.byWatchedProject(p)).isEmpty();
+
+    watch(user1, p, null);
+    assertAccounts(internalAccountQuery.byWatchedProject(p), user1);
+
+    watch(user2, p, "keyword");
+    assertAccounts(internalAccountQuery.byWatchedProject(p), user1, user2);
+
+    watch(user3, p2, "keyword");
+    watch(user3, allProjects, "keyword");
+    assertAccounts(internalAccountQuery.byWatchedProject(p), user1, user2);
+    assertAccounts(internalAccountQuery.byWatchedProject(p2), user3);
+    assertAccounts(internalAccountQuery.byWatchedProject(allProjects), user3);
+  }
+
+  @Test
   public void withLimit() throws Exception {
     String domain = name("test.com");
     AccountInfo user1 = newAccountWithEmail("user1", "user1@" + domain);
@@ -360,6 +391,25 @@
     assertQuery("username:" + user1.username, user1);
   }
 
+  // reindex permissions are tested by {@link AccountIT#reindexPermissions}
+  @Test
+  public void reindex() throws Exception {
+    AccountInfo user1 = newAccountWithFullName("tester", "Test Usre");
+
+    // update account in the database so that account index is stale
+    String newName = "Test User";
+    Account account = db.accounts().get(new Account.Id(user1._accountId));
+    account.setFullName(newName);
+    db.accounts().update(Collections.singleton(account));
+
+    assertQuery("name:" + quote(user1.name), user1);
+    assertQuery("name:" + quote(newName));
+
+    gApi.accounts().id(user1.username).index();
+    assertQuery("name:" + quote(user1.name));
+    assertQuery("name:" + quote(newName), user1);
+  }
+
   protected AccountInfo newAccount(String username) throws Exception {
     return newAccountWithEmail(username, null);
   }
@@ -394,6 +444,24 @@
     return gApi.accounts().id(id.get()).get();
   }
 
+  protected Project.NameKey createProject(String name) throws RestApiException {
+    gApi.projects().create(name);
+    return new Project.NameKey(name);
+  }
+
+  protected void watch(AccountInfo account, Project.NameKey project,
+      String filter) throws RestApiException {
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = project.get();
+    pwi.filter = filter;
+    pwi.notifyAbandonedChanges = true;
+    pwi.notifyNewChanges = true;
+    pwi.notifyAllComments = true;
+    projectsToWatch.add(pwi);
+    gApi.accounts().id(account._accountId).setWatchedProjects(projectsToWatch);
+  }
+
   protected String quote(String s) {
     return "\"" + s + "\"";
   }
@@ -460,6 +528,14 @@
     return result;
   }
 
+  protected void assertAccounts(List<AccountState> accounts,
+      AccountInfo... expectedAccounts) {
+    assertThat(accounts.stream().map(a -> a.getAccount().getId().get())
+        .collect(toList()))
+            .containsExactlyElementsIn(Arrays.asList(expectedAccounts).stream()
+                .map(a -> a._accountId).collect(toList()));
+  }
+
   private String format(QueryRequest query, List<AccountInfo> actualIds,
       List<AccountInfo> expectedAccounts) {
     StringBuilder b = new StringBuilder();
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 3e3ec13..5e5a047 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -21,6 +21,7 @@
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.MINUTES;
 import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.FluentIterable;
@@ -38,6 +39,7 @@
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -52,7 +54,9 @@
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.Sequences;
@@ -62,12 +66,18 @@
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeTriplet;
 import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.edit.ChangeEditModifier;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.index.IndexConfig;
+import com.google.gerrit.server.index.QueryOptions;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.index.change.IndexedChangeQuery;
+import com.google.gerrit.server.index.change.StalenessChecker;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.NoteDbChangeState;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.RequestContext;
@@ -87,6 +97,7 @@
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.util.SystemReader;
 import org.junit.After;
@@ -97,6 +108,7 @@
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
@@ -119,8 +131,10 @@
   @Inject protected ChangeQueryBuilder queryBuilder;
   @Inject protected GerritApi gApi;
   @Inject protected IdentifiedUser.GenericFactory userFactory;
+  @Inject protected ChangeEditModifier changeEditModifier;
   @Inject protected ChangeIndexCollection indexes;
   @Inject protected ChangeIndexer indexer;
+  @Inject protected IndexConfig indexConfig;
   @Inject protected InMemoryDatabase schemaFactory;
   @Inject protected InMemoryRepositoryManager repoManager;
   @Inject protected InternalChangeQuery internalChangeQuery;
@@ -374,6 +388,9 @@
 
     assertQuery("owner:" + userId.get(), change1);
     assertQuery("owner:" + user2, change2);
+
+    String nameEmail = user.asIdentifiedUser().getNameEmail();
+    assertQuery("owner: \"" + nameEmail + "\"", change1);
   }
 
   @Test
@@ -1493,6 +1510,35 @@
   }
 
   @Test
+  public void hasEdit() throws Exception {
+    Account.Id user1 = createAccount("user1");
+    Account.Id user2 = createAccount("user2");
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    PatchSet ps1 = db.patchSets().get(change1.currentPatchSetId());
+    Change change2 = insert(repo, newChange(repo));
+    PatchSet ps2 = db.patchSets().get(change2.currentPatchSetId());
+
+    requestContext.setContext(newRequestContext(user1));
+    assertQuery("has:edit");
+    assertThat(changeEditModifier.createEdit(change1, ps1))
+        .isEqualTo(RefUpdate.Result.NEW);
+    assertThat(changeEditModifier.createEdit(change2, ps2))
+        .isEqualTo(RefUpdate.Result.NEW);
+
+    requestContext.setContext(newRequestContext(user2));
+    assertQuery("has:edit");
+    assertThat(changeEditModifier.createEdit(change2, ps2))
+        .isEqualTo(RefUpdate.Result.NEW);
+
+    requestContext.setContext(newRequestContext(user1));
+    assertQuery("has:edit", change2, change1);
+
+    requestContext.setContext(newRequestContext(user2));
+    assertQuery("has:edit", change2);
+  }
+
+  @Test
   public void byCommitsOnBranchNotMerged() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     int n = 10;
@@ -1576,6 +1622,123 @@
     cd.currentApprovals();
   }
 
+  @Test
+  public void reindexIfStale() throws Exception {
+    Account.Id user = createAccount("user");
+    Project.NameKey project = new Project.NameKey("repo");
+    TestRepository<Repo> repo = createProject(project.get());
+    Change change = insert(repo, newChange(repo));
+    PatchSet ps = db.patchSets().get(change.currentPatchSetId());
+
+    requestContext.setContext(newRequestContext(user));
+    assertThat(changeEditModifier.createEdit(change, ps))
+        .isEqualTo(RefUpdate.Result.NEW);
+    assertQuery("has:edit", change);
+    assertThat(indexer.reindexIfStale(project, change.getId()).get()).isFalse();
+
+    // Delete edit ref behind index's back.
+    RefUpdate ru = repo.getRepository().updateRef(
+        RefNames.refsEdit(user, change.getId(), ps.getId()));
+    ru.setForceUpdate(true);
+    assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+
+    // Index is stale.
+    assertQuery("has:edit", change);
+    assertThat(indexer.reindexIfStale(project, change.getId()).get()).isTrue();
+    assertQuery("has:edit");
+  }
+
+  @Test
+  public void refStateFields() throws Exception {
+    Account.Id user = createAccount("user");
+    Project.NameKey project = new Project.NameKey("repo");
+    TestRepository<Repo> repo = createProject(project.get());
+    String path = "file";
+    RevCommit commit = repo.parseBody(
+        repo.commit().message("one").add(path, "contents").create());
+    Change change = insert(repo, newChangeForCommit(repo, commit));
+    Change.Id id = change.getId();
+    int c = id.get();
+    PatchSet ps = db.patchSets().get(change.currentPatchSetId());
+    requestContext.setContext(newRequestContext(user));
+
+    // Ensure one of each type of supported ref is present for the change. If
+    // any more refs are added, update this test to reflect them.
+
+    // Edit
+    assertThat(changeEditModifier.createEdit(change, ps))
+        .isEqualTo(RefUpdate.Result.NEW);
+
+    // Star
+    gApi.accounts()
+        .self()
+        .starChange(change.getId().toString());
+
+    if (notesMigration.readChanges()) {
+      // Robot comment.
+      ReviewInput rin = new ReviewInput();
+      RobotCommentInput rcin = new RobotCommentInput();
+      rcin.robotId = "happyRobot";
+      rcin.robotRunId = "1";
+      rcin.line = 1;
+      rcin.message = "nit: trailing whitespace";
+      rcin.path = path;
+      rin.robotComments = ImmutableMap.of(path, ImmutableList.of(rcin));
+      gApi.changes().id(c).current().review(rin);
+    }
+
+    // Draft.
+    DraftInput din = new DraftInput();
+    din.path = path;
+    din.line = 1;
+    din.message = "draft";
+    gApi.changes().id(c).current().createDraft(din);
+
+    if (notesMigration.readChanges()) {
+      // Force NoteDb primary.
+      change = ReviewDbUtil.unwrapDb(db).changes().get(id);
+      change.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
+      ReviewDbUtil.unwrapDb(db).changes().update(Collections.singleton(change));
+      indexer.index(db, change);
+    }
+
+    QueryOptions opts = IndexedChangeQuery.createOptions(
+        indexConfig, 0, 1, StalenessChecker.FIELDS);
+    ChangeData cd = indexes.getSearchIndex().get(id, opts).get();
+
+    String cs = RefNames.shard(c);
+    int u = user.get();
+    String us = RefNames.shard(u);
+
+    List<String> expectedStates = Lists.newArrayList(
+        "repo:refs/users/" + us + "/edit-" + c + "/1",
+        "All-Users:refs/starred-changes/" + cs + "/" + u);
+    if (notesMigration.readChanges()) {
+      expectedStates.add("repo:refs/changes/" + cs + "/meta");
+      expectedStates.add("repo:refs/changes/" + cs + "/robot-comments");
+      expectedStates.add("All-Users:refs/draft-comments/" + cs + "/" + u);
+    }
+    assertThat(
+            cd.getRefStates().stream()
+                .map(String::new)
+                // Omit SHA-1, we're just concerned with the project/ref names.
+                .map(s -> s.substring(0, s.lastIndexOf(':')))
+                .collect(toList()))
+        .containsExactlyElementsIn(expectedStates);
+
+    List<String> expectedPatterns = Lists.newArrayList(
+        "repo:refs/users/*/edit-" + c + "/*");
+    if (notesMigration.readChanges()) {
+      expectedPatterns.add("All-Users:refs/starred-changes/" + cs + "/*");
+      expectedPatterns.add("All-Users:refs/draft-comments/" + cs + "/*");
+    }
+    assertThat(
+            cd.getRefStatePatterns().stream()
+                .map(String::new)
+                .collect(toList()))
+        .containsExactlyElementsIn(expectedPatterns);
+  }
+
   protected ChangeInserter newChange(TestRepository<Repo> repo)
       throws Exception {
     return newChange(repo, null, null, null, null);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
index 038abda..70493e8 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.testutil.InMemoryModule;
 import com.google.gerrit.testutil.InMemoryRepositoryManager.Repo;
@@ -52,4 +53,15 @@
     assertQuery("message:one.two", change2);
     assertQuery("message:one two", change2);
   }
+
+  @Test
+  public void byOwnerInvalidQuery() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo), userId);
+    String nameEmail = user.asIdentifiedUser().getNameEmail();
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("Cannot create full-text query with value: \\");
+    assertQuery("owner: \"" + nameEmail + "\"\\", change1);
+  }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritBaseTests.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritBaseTests.java
index 967e3f9..3edc9f4 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritBaseTests.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritBaseTests.java
@@ -14,12 +14,19 @@
 
 package com.google.gerrit.testutil;
 
+import com.google.gwtorm.client.KeyUtil;
+import com.google.gwtorm.server.StandardKeyEncoder;
+
 import org.junit.Ignore;
 import org.junit.Rule;
 import org.junit.rules.ExpectedException;
 
 @Ignore
 public abstract class GerritBaseTests {
+  static {
+    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
+  }
+
   @Rule
   public ExpectedException exception = ExpectedException.none();
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java
index f98d63f..ddc4196 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java
@@ -37,12 +37,10 @@
 
   public static class Description extends DfsRepositoryDescription {
     private final Project.NameKey name;
-    private String desc;
 
     private Description(Project.NameKey name) {
       super(name.get());
       this.name = name;
-      desc = "In-memory repository " + name.get();
     }
 
     public Project.NameKey getProject() {
@@ -51,6 +49,8 @@
   }
 
   public static class Repo extends InMemoryRepository {
+    private String description;
+
     private Repo(Project.NameKey name) {
       super(new Description(name));
       // TODO(dborowitz): Allow atomic transactions when this is supported:
@@ -62,6 +62,16 @@
     public Description getDescription() {
       return (Description) super.getDescription();
     }
+
+    @Override
+    public String getGitwebDescription() {
+      return description;
+    }
+
+    @Override
+    public void setGitwebDescription(String d) {
+      description = d;
+    }
   }
 
   private Map<String, Repo> repos = new HashMap<>();
@@ -97,22 +107,6 @@
     return ImmutableSortedSet.copyOf(names);
   }
 
-  @Override
-  public synchronized String getProjectDescription(Project.NameKey name)
-      throws RepositoryNotFoundException {
-    return get(name).getDescription().desc;
-  }
-
-  @Override
-  public synchronized void setProjectDescription(Project.NameKey name,
-      String description) {
-    try {
-      get(name).getDescription().desc = description;
-    } catch (RepositoryNotFoundException e) {
-      // Ignore.
-    }
-  }
-
   public synchronized void deleteRepository(Project.NameKey name) {
     repos.remove(normalize(name));
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java
index 23950d4..8aadb92 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java
@@ -18,7 +18,6 @@
 
 import com.google.common.base.Enums;
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
 
 public enum NoteDbMode {
   /** NoteDb is disabled. */
@@ -39,10 +38,6 @@
   private static final String VAR = "GERRIT_NOTEDB";
 
   public static NoteDbMode get() {
-    if (isEnvVarTrue("GERRIT_ENABLE_NOTEDB")) {
-      // TODO(dborowitz): Remove once GerritForge CI is migrated.
-      return READ_WRITE;
-    }
     String value = System.getenv(VAR);
     if (Strings.isNullOrEmpty(value)) {
       return OFF;
@@ -57,9 +52,4 @@
   public static boolean readWrite() {
     return get() == READ_WRITE;
   }
-
-  private static boolean isEnvVarTrue(String name) {
-    String value = Strings.nullToEmpty(System.getenv(name)).toLowerCase();
-    return ImmutableList.of("yes", "y", "true", "1").contains(value);
-  }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java
index d658b7f..93a508c 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java
@@ -96,8 +96,8 @@
   }
 
   private List<ChangeControl> changeFromNotesFactory(String id, CurrentUser currentUser)
-      throws OrmException {
-    return changeNotesFactory.create(db, Arrays.asList(Change.Id.parse(id)))
+      throws OrmException, UnloggedFailure {
+    return changeNotesFactory.create(db, parseId(id))
         .stream()
         .map(changeNote -> controlForChange(changeNote, currentUser))
         .filter(changeControl -> changeControl.isPresent())
@@ -105,7 +105,16 @@
         .collect(toList());
   }
 
-  private Optional<ChangeControl> controlForChange(ChangeNotes change, CurrentUser user) {
+  private List<Change.Id> parseId(String id) throws UnloggedFailure {
+    try {
+     return Arrays.asList(new Change.Id(Integer.parseInt(id)));
+    } catch (NumberFormatException e) {
+      throw new UnloggedFailure(2, "Invalid change ID " + id, e);
+    }
+  }
+
+  private Optional<ChangeControl> controlForChange(ChangeNotes change,
+      CurrentUser user) {
     try {
       return Optional.of(changeControlFactory.controlFor(change, user));
     } catch (NoSuchChangeException e) {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
index 85b1f32..d3065df 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
@@ -44,7 +44,7 @@
     try {
       changeArgumentParser.addChange(token, changes, null, false);
     } catch (UnloggedFailure e) {
-      throw new IllegalArgumentException(e.getMessage(), e);
+      writeError("warning", e.getMessage());
     } catch (OrmException e) {
       throw new IllegalArgumentException("database is down", e);
     }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ReviewCommand.java
index 45bf649..02aab64 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ReviewCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ReviewCommand.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.MoveInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RestoreInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -108,6 +109,9 @@
   @Option(name = "--rebase", usage = "rebase the specified change(s)")
   private boolean rebaseChange;
 
+  @Option(name = "--move", usage = "move the specified change(s)", metaVar = "BRANCH")
+  private String moveToBranch;
+
   @Option(name = "--submit", aliases = "-s", usage = "submit the specified patch set(s)")
   private boolean submitChange;
 
@@ -167,6 +171,9 @@
       if (rebaseChange) {
         throw die("abandon and rebase actions are mutually exclusive");
       }
+      if (moveToBranch != null) {
+        throw die("abandon and move actions are mutually exclusive");
+      }
     }
     if (publishPatchSet) {
       if (restoreChange) {
@@ -201,6 +208,9 @@
       if (rebaseChange) {
         throw die("json and rebase actions are mutually exclusive");
       }
+      if (moveToBranch != null) {
+        throw die("json and move actions are mutually exclusive");
+      }
       if (changeTag != null) {
         throw die("json and tag actions are mutually exclusive");
       }
@@ -289,7 +299,7 @@
     review.labels.putAll(customLabels);
 
     // We don't need to add the review comment when abandoning/restoring.
-    if (abandonChange || restoreChange) {
+    if (abandonChange || restoreChange || moveToBranch != null) {
       review.message = null;
     }
 
@@ -308,6 +318,13 @@
         applyReview(patchSet, review);
       }
 
+      if (moveToBranch != null) {
+        MoveInput moveInput = new MoveInput();
+        moveInput.destinationBranch = moveToBranch;
+        moveInput.message = Strings.emptyToNull(changeComment);
+        changeApi(patchSet).move(moveInput);
+      }
+
       if (rebaseChange) {
         revisionApi(patchSet).rebase();
       }
diff --git a/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java b/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
index 34f2672..4eee495 100644
--- a/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
+++ b/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
@@ -25,12 +25,12 @@
 import com.google.common.collect.Maps;
 import com.google.gerrit.extensions.restapi.Url;
 
-import org.apache.http.client.utils.DateUtils;
-
 import java.io.BufferedReader;
 import java.io.UnsupportedEncodingException;
 import java.net.URLDecoder;
 import java.security.Principal;
+import java.time.Instant;
+import java.time.format.DateTimeFormatter;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Enumeration;
@@ -55,6 +55,8 @@
 /** Simple fake implementation of {@link HttpServletRequest}. */
 public class FakeHttpServletRequest implements HttpServletRequest {
   public static final String SERVLET_PATH = "/b";
+  public static final DateTimeFormatter rfcDateformatter =
+      DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss ZZZ");
 
   private final Map<String, Object> attributes;
   private final ListMultimap<String, String> headers;
@@ -263,7 +265,8 @@
   @Override
   public long getDateHeader(String name) {
     String v = getHeader(name);
-    return v != null ? DateUtils.parseDate(v).getTime() : 0;
+    return v == null ? 0 :
+        rfcDateformatter.parse(v, Instant::from).getEpochSecond();
   }
 
   @Override
diff --git a/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java b/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
index 6c46acd..94168f4 100644
--- a/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
+++ b/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
 import com.google.gerrit.pgm.util.LogFileCompressor;
+import com.google.gerrit.server.LibModuleLoader;
 import com.google.gerrit.server.account.InternalAccountDirectory;
 import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
 import com.google.gerrit.server.change.ChangeCleanupRunner;
@@ -339,6 +340,7 @@
     });
     modules.add(new GarbageCollectionModule());
     modules.add(new ChangeCleanupRunner.Module());
+    modules.addAll(LibModuleLoader.loadModules(cfgInjector));
     return cfgInjector.createChildInjector(modules);
   }
 
diff --git a/lib/BUCK b/lib/BUCK
index c01d75a..ad860ff 100644
--- a/lib/BUCK
+++ b/lib/BUCK
@@ -20,6 +20,7 @@
 define_license(name = 'icu4j')
 define_license(name = 'jgit')
 define_license(name = 'jsch')
+define_license(name = 'jsoup')
 define_license(name = 'MPL1.1')
 define_license(name = 'moment')
 define_license(name = 'OFL1.1')
diff --git a/lib/BUILD b/lib/BUILD
index 292560b..a4f1d51 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -32,7 +32,7 @@
 
 java_library(
   name = 'gwtjsonrpc_src',
-  exports = ['@gwtjsonrpc_src//jar'],
+  exports = ['@gwtjsonrpc//jar:src'],
   visibility = ['//visibility:public'],
   data = ['//lib:LICENSE-Apache2.0'],
 )
@@ -53,7 +53,7 @@
 
 java_library(
   name = 'gwtorm_client_src',
-  exports = ['@gwtorm_client_src//jar'],
+  exports = ['@gwtorm_client//jar:src'],
   visibility = ['//visibility:public'],
   data = ['//lib:LICENSE-Apache2.0'],
 )
diff --git a/lib/JGIT_VERSION b/lib/JGIT_VERSION
index 569cf59..a6ed50a 100644
--- a/lib/JGIT_VERSION
+++ b/lib/JGIT_VERSION
@@ -1,4 +1,5 @@
 include_defs('//lib/jgit/jgit.bzl')
 include_defs('//lib/maven.defs')
 
-REPO = MAVEN_CENTRAL # Leave here even if set to MAVEN_CENTRAL.
+REPO = GERRIT
+#REPO = MAVEN_CENTRAL # Leave here even if set to MAVEN_CENTRAL.
diff --git a/lib/LICENSE-jsoup b/lib/LICENSE-jsoup
new file mode 100644
index 0000000..9e15540
--- /dev/null
+++ b/lib/LICENSE-jsoup
@@ -0,0 +1,21 @@
+The MIT License
+
+© 2009-2016, Jonathan Hedley <jonathan@hedley.net>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/lib/gwt/BUILD b/lib/gwt/BUILD
index 46d8f6d..5d826d2 100644
--- a/lib/gwt/BUILD
+++ b/lib/gwt/BUILD
@@ -15,16 +15,31 @@
 ]]
 
 java_library(
+  name = 'user-neverlink',
+  exports = ['@user//jar'],
+  visibility = ['//visibility:public'],
+  neverlink = 1,
+  data = ['//lib:LICENSE-Apache2.0'],
+)
+
+java_library(
+  name = 'dev-neverlink',
+  exports = ['@dev//jar'],
+  visibility = ['//visibility:public'],
+  neverlink = 1,
+  data = ['//lib:LICENSE-Apache2.0'],
+)
+
+java_library(
   name = 'javax-validation_src',
-  exports = ['@javax_validation_src//jar'],
+  exports = ['@javax_validation//jar:src'],
   visibility = ['//visibility:public'],
   data = ['//lib:LICENSE-Apache2.0'],
 )
 
 java_library(
   name = 'jsinterop-annotations_src',
-  exports = ['@jsinterop_annotations_src//jar'],
+  exports = ['@jsinterop_annotations//jar:src'],
   visibility = ['//visibility:public'],
   data = ['//lib:LICENSE-Apache2.0'],
 )
-
diff --git a/lib/jgit/jgit.bzl b/lib/jgit/jgit.bzl
index 996e44d..f87603f 100644
--- a/lib/jgit/jgit.bzl
+++ b/lib/jgit/jgit.bzl
@@ -1,3 +1,4 @@
-JGIT_VERS = '4.5.0.201609210915-r'
-DOC_VERS = JGIT_VERS # Set to VERS unless using a snapshot
+JGIT_VERS = '4.5.0.201609210915-r.115-g81f9c1843'
+DOC_VERS = '4.5.0.201609210915-r'
+#DOC_VERS = JGIT_VERS # Set to VERS unless using a snapshot
 JGIT_DOC_URL="http://download.eclipse.org/jgit/site/" + DOC_VERS + "/apidocs"
diff --git a/lib/jgit/org.eclipse.jgit.archive/BUCK b/lib/jgit/org.eclipse.jgit.archive/BUCK
index 02f99c6..c0272af 100644
--- a/lib/jgit/org.eclipse.jgit.archive/BUCK
+++ b/lib/jgit/org.eclipse.jgit.archive/BUCK
@@ -4,7 +4,7 @@
 maven_jar(
   name = 'jgit-archive',
   id = 'org.eclipse.jgit:org.eclipse.jgit.archive:' + JGIT_VERS,
-  sha1 = '2db2e7666672a31fa41b7e1dadcba51df6d30954',
+  sha1 = '4a5d058915400c1ef497bfeeb5e87d235213e273',
   license = 'jgit',
   repository = REPO,
   deps = ['//lib/jgit/org.eclipse.jgit:jgit'],
diff --git a/lib/jgit/org.eclipse.jgit.http.server/BUCK b/lib/jgit/org.eclipse.jgit.http.server/BUCK
index 5dd3777..29e7e27 100644
--- a/lib/jgit/org.eclipse.jgit.http.server/BUCK
+++ b/lib/jgit/org.eclipse.jgit.http.server/BUCK
@@ -4,7 +4,7 @@
 maven_jar(
   name = 'jgit-servlet',
   id = 'org.eclipse.jgit:org.eclipse.jgit.http.server:' + JGIT_VERS,
-  sha1 = '6e36638888918d9941dddec7e2abe1f162cc74d9',
+  sha1 = '927990025d2970995dbb58f03763eeb776fec8fd',
   license = 'jgit',
   repository = REPO,
   deps = ['//lib/jgit/org.eclipse.jgit:jgit'],
diff --git a/lib/jgit/org.eclipse.jgit.http.server/BUILD b/lib/jgit/org.eclipse.jgit.http.server/BUILD
index a453513..f4c66c0 100644
--- a/lib/jgit/org.eclipse.jgit.http.server/BUILD
+++ b/lib/jgit/org.eclipse.jgit.http.server/BUILD
@@ -1,15 +1,7 @@
-load('//tools/bzl:unsign.bzl', 'unsign_jars')
-
 java_library(
-  name = 'jgit-servlet-signed',
+  name = 'jgit-servlet',
   exports = ['@jgit_servlet//jar'],
   runtime_deps = ['//lib/jgit/org.eclipse.jgit:jgit'],
   visibility = ['//visibility:public'],
   data = ['//lib:LICENSE-jgit'],
 )
-
-unsign_jars(
-  name = 'jgit-servlet',
-  deps = [':jgit-servlet-signed'],
-  visibility = ['//visibility:public'],
-)
diff --git a/lib/jgit/org.eclipse.jgit.junit/BUCK b/lib/jgit/org.eclipse.jgit.junit/BUCK
index e5cd5c0..255b47c 100644
--- a/lib/jgit/org.eclipse.jgit.junit/BUCK
+++ b/lib/jgit/org.eclipse.jgit.junit/BUCK
@@ -4,7 +4,7 @@
 maven_jar(
   name = 'junit',
   id = 'org.eclipse.jgit:org.eclipse.jgit.junit:' + JGIT_VERS,
-  sha1 = 'e8fb1d81f588c3174a9730bdecdbde9faa04140a',
+  sha1 = '8e3cb9b1f632fdfea76b04c286a2c0d8d260ebce',
   license = 'DO_NOT_DISTRIBUTE',
   repository = REPO,
   unsign = True,
diff --git a/lib/jgit/org.eclipse.jgit.junit/BUILD b/lib/jgit/org.eclipse.jgit.junit/BUILD
index 7f31261..10e9874 100644
--- a/lib/jgit/org.eclipse.jgit.junit/BUILD
+++ b/lib/jgit/org.eclipse.jgit.junit/BUILD
@@ -1,15 +1,7 @@
-load('//tools/bzl:unsign.bzl', 'unsign_jars')
-
 java_library(
-  name = 'junit-signed',
+  name = 'junit',
   exports = ['@jgit_junit//jar'],
   runtime_deps = ['//lib/jgit/org.eclipse.jgit:jgit'],
   visibility = ['//visibility:public'],
   data = ['//lib:LICENSE-DO_NOT_DISTRIBUTE'],
 )
-
-unsign_jars(
-  name = 'junit',
-  deps = [':junit-signed'],
-    visibility = ['//visibility:public'],
-)
diff --git a/lib/jgit/org.eclipse.jgit/BUCK b/lib/jgit/org.eclipse.jgit/BUCK
index 3f463df..4f5da75 100644
--- a/lib/jgit/org.eclipse.jgit/BUCK
+++ b/lib/jgit/org.eclipse.jgit/BUCK
@@ -4,8 +4,8 @@
 maven_jar(
   name = 'jgit',
   id = 'org.eclipse.jgit:org.eclipse.jgit:' + JGIT_VERS,
-  bin_sha1 = '3e3d0b73dcf4ad649f37758ea8502d92f3d299de',
-  src_sha1 = 'fc352952db91a4046e4b832145eb2dc8afce8db1',
+  bin_sha1 = '34315f71bb9becf6ff75947a9c43c415b929ec21',
+  src_sha1 = '8320c18472870904eb7fb860af353fea818d07e4',
   license = 'jgit',
   repository = REPO,
   unsign = True,
@@ -19,7 +19,7 @@
 
 maven_jar(
   name = 'javaewah',
-  id = 'com.googlecode.javaewah:JavaEWAH:0.7.9',
-  sha1 = 'eceaf316a8faf0e794296ebe158ae110c7d72a5a',
+  id = 'com.googlecode.javaewah:JavaEWAH:1.1.6',
+  sha1 = '94ad16d728b374d65bd897625f3fbb3da223a2b6',
   license = 'Apache2.0',
 )
diff --git a/lib/jgit/org.eclipse.jgit/BUILD b/lib/jgit/org.eclipse.jgit/BUILD
index ac8db97..e220a96 100644
--- a/lib/jgit/org.eclipse.jgit/BUILD
+++ b/lib/jgit/org.eclipse.jgit/BUILD
@@ -1,7 +1,5 @@
-load('//tools/bzl:unsign.bzl', 'unsign_jars')
-
 java_library(
-  name = 'jgit-signed',
+  name = 'jgit',
   exports = ['@jgit//jar'],
   runtime_deps = [':javaewah'],
   visibility = ['//visibility:public'],
@@ -14,9 +12,3 @@
   visibility = ['//visibility:public'],
   data = ['//lib:LICENSE-Apache2.0'],
 )
-
-unsign_jars(
-  name = 'jgit',
-  deps = [':jgit-signed'],
-  visibility = ['//visibility:public'],
-)
diff --git a/lib/jsoup/BUCK b/lib/jsoup/BUCK
new file mode 100644
index 0000000..8d8dab0
--- /dev/null
+++ b/lib/jsoup/BUCK
@@ -0,0 +1,20 @@
+include_defs('//lib/maven.defs')
+
+VERSION = '1.9.2'
+
+java_library(
+  name = 'jsoup',
+  exported_deps = [
+    ':jsoup_library',
+  ],
+  visibility = ['PUBLIC'],
+)
+
+maven_jar(
+  name = 'jsoup_library',
+  id = 'org.jsoup:jsoup:' + VERSION,
+  sha1 = '5e3bda828a80c7a21dfbe2308d1755759c2fd7b4',
+  license = 'jsoup',
+  exclude_java_sources = True,
+  visibility = ['PUBLIC'],
+)
diff --git a/lib/jsoup/BUILD b/lib/jsoup/BUILD
new file mode 100644
index 0000000..e4d58c9
--- /dev/null
+++ b/lib/jsoup/BUILD
@@ -0,0 +1,6 @@
+java_library(
+  name = 'jsoup',
+  exports = ['@jsoup//jar'],
+  visibility = ['//visibility:public'],
+  data = ['//lib:LICENSE-jsoup'],
+)
diff --git a/plugins/BUILD b/plugins/BUILD
index 2fe6254..27690c8 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -1,17 +1,9 @@
 load('//tools/bzl:genrule2.bzl', 'genrule2')
-
-CORE = [
-  'commit-message-length-validator',
-  'download-commands',
-  'hooks',
-  'replication',
-  'reviewnotes',
-  'singleusergroup'
-]
+load('//tools/bzl:plugins.bzl', 'CORE_PLUGINS')
 
 genrule2(
   name = 'core',
-  srcs = ['//plugins/%s:%s_deploy.jar' % (n, n) for n in CORE],
+  srcs = ['//plugins/%s:%s.jar' % (n, n) for n in CORE_PLUGINS],
   cmd = 'mkdir -p $$TMP/WEB-INF/plugins;' +
     'for s in $(SRCS) ; do ' +
     'ln -s $$ROOT/$$s $$TMP/WEB-INF/plugins;done;' +
diff --git a/plugins/cookbook-plugin b/plugins/cookbook-plugin
index 0162fc1..747336d 160000
--- a/plugins/cookbook-plugin
+++ b/plugins/cookbook-plugin
@@ -1 +1 @@
-Subproject commit 0162fc1b0d6f6ef03e818adef7657712314fe14f
+Subproject commit 747336da4bcca8000badf6e4ed05ada536645b16
diff --git a/plugins/download-commands b/plugins/download-commands
index 6326db6..76cfbcd 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit 6326db67dfa45b13a0c427643bbfa617c18855d7
+Subproject commit 76cfbcd59988392da2a7c5648bc6975e5c835ee1
diff --git a/plugins/replication b/plugins/replication
index 531ed17..bb1ee89 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 531ed177aaab613c5830b7578df2dd4d84a7f319
+Subproject commit bb1ee89bdf6bdd2dfdf5d16a11a18b077c7c523d
diff --git a/polygerrit-ui/BUCK b/polygerrit-ui/BUCK
index 206065d..b5dee99 100644
--- a/polygerrit-ui/BUCK
+++ b/polygerrit-ui/BUCK
@@ -6,6 +6,7 @@
     '//lib/js:es6-promise',
     '//lib/js:fetch',
     '//lib/js:highlightjs',
+    '//lib/js:iron-a11y-keys-behavior',
     '//lib/js:iron-autogrow-textarea',
     '//lib/js:iron-dropdown',
     '//lib/js:iron-input',
diff --git a/polygerrit-ui/BUILD b/polygerrit-ui/BUILD
index 4cc6899..67a5456 100644
--- a/polygerrit-ui/BUILD
+++ b/polygerrit-ui/BUILD
@@ -6,12 +6,13 @@
 load('//tools/bzl:genrule2.bzl', 'genrule2')
 
 bower_component_bundle(
-  name = "polygerrit_components",
+  name = "polygerrit_components.bower_components",
   deps = [
     '//lib/js:es6-promise',
     '//lib/js:fetch',
     # TODO(hanwen): this is inserted separately in the UI zip. Do we need this here?
     '//lib/js:highlightjs',
+    '//lib/js:iron-a11y-keys-behavior',
     '//lib/js:iron-autogrow-textarea',
     '//lib/js:iron-dropdown',
     '//lib/js:iron-input',
@@ -22,3 +23,21 @@
     '//lib/js:polymer',
     '//lib/js:promise-polyfill',
 ])
+
+
+genrule2(
+  name = 'fonts',
+  cmd = ' && '.join([
+    'mkdir -p $$TMP/fonts',
+    'cp $(SRCS) $$TMP/fonts/',
+    'cd $$TMP',
+    "find fonts/ -exec touch -t 198001010000 '{}' ';'",
+    'zip -qr $$ROOT/$@ fonts',
+  ]),
+  srcs = [
+    '//lib/fonts:sourcecodepro',
+  ],
+  outs = ['fonts.zip',],
+  visibility = ['//visibility:public'],
+  output_to_bindir = 1,
+)
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 518ba52..91240e5 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -18,7 +18,7 @@
     'test/**',
     '**/*_test.html',
     ]),
-  deps = [ "//polygerrit-ui:polygerrit_components"],
+  deps = [ "//polygerrit-ui:polygerrit_components.bower_components"],
 )
 
 filegroup(
@@ -65,7 +65,7 @@
   name = 'test_components',
   testonly = 1,
   deps = [
-    '//polygerrit-ui:polygerrit_components',
+    '//polygerrit-ui:polygerrit_components.bower_components',
     '//lib/js:iron-test-helpers',
     '//lib/js:test-fixture',
     '//lib/js:web-component-tester',
@@ -87,9 +87,10 @@
   outs = [ "pg_code.zip", ],
   srcs = [ ":pg_code" ],
   cmd = " && ".join([
-    ("tar -cf- --mtime=1980-01-01\\ 00:00 $(locations :pg_code) |"
+    ("tar -hcf- $(locations :pg_code) |"
      + " tar --strip-components=2 -C $$TMP/ -xf-"),
     "cd $$TMP",
+    "find . -exec touch -t 198001010000 '{}' ';'",
     "zip -rq $$ROOT/$@ *"])
 )
 
@@ -101,6 +102,7 @@
     ":test_components.zip",
     "test/index.html",
   ],
+  size = "large",
   # Should not run sandboxed.
   tags = ["local", "manual"],
 )
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
new file mode 100644
index 0000000..c52c12e
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
@@ -0,0 +1,44 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<script>
+(function(window) {
+  'use strict';
+
+  /** @polymerBehavior Gerrit.PatchSetBehavior */
+  var PatchSetBehavior = {
+    /**
+     * Given an object of revisions, get a particular revision based on patch
+     * num.
+     *
+     * @param {Object} revisions
+     * @param {number|string} patchNum
+     * @return {Object}
+     */
+    getRevisionNumber: function(revisions, patchNum) {
+      patchNum = parseInt(patchNum, 10);
+      for (var rev in revisions) {
+        if (revisions.hasOwnProperty(rev) &&
+            revisions[rev]._number === patchNum) {
+          return revisions[rev];
+        }
+      }
+    },
+  };
+
+  window.Gerrit = window.Gerrit || {};
+  window.Gerrit.PatchSetBehavior = PatchSetBehavior;
+})(window);
+</script>
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
new file mode 100644
index 0000000..d9e98f4
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
@@ -0,0 +1,38 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<!-- Polymer included for the html import polyfill. -->
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../bower_components/web-component-tester/browser.js"></script>
+<title>gr-patch-set-behavior</title>
+
+<link rel="import" href="../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-patch-set-behavior.html">
+
+<script>
+  suite('gr-path-list-behavior tests', function() {
+    test('getRevisionNumber', function() {
+      var get = Gerrit.PatchSetBehavior.getRevisionNumber;
+      var revisions = [
+        {_number: 0},
+        {_number: 1},
+        {_number: 2},
+      ];
+      assert.deepEqual(get(revisions, '1'), revisions[1]);
+      assert.deepEqual(get(revisions, 2), revisions[2]);
+      assert.equal(get(revisions, '3'), undefined);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior.html
index f636650..f2c6ab8 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior.html
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior.html
@@ -14,69 +14,39 @@
 limitations under the License.
 -->
 <link rel="import" href="../bower_components/polymer/polymer.html">
+<link rel="import" href="../bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
+
 <script>
 (function(window) {
   'use strict';
 
-  /** @polymerBehavior Gerrit.KeyboardShortcutBehavior */
-  var KeyboardShortcutBehavior = {
-    // Set of identifiers currently blocking keyboard shortcuts. Stored as
-    // a map of string to the value of true.
-    _disablers: {},
+  var getKeyboardEvent = function(e) {
+    return Polymer.dom(e.detail ? e.detail.keyboardEvent : e).event;
+  };
 
-    properties: {
-      keyEventTarget: {
-        type: Object,
-        value: function() { return this; },
-      },
-
-      _boundKeyHandler: {
-        type: Function,
-        readonly: true,
-        value: function() { return this._handleKey.bind(this); },
-      },
-    },
-
-    attached: function() {
-      this.keyEventTarget.addEventListener('keydown', this._boundKeyHandler);
-    },
-
-    detached: function() {
-      this.keyEventTarget.removeEventListener('keydown', this._boundKeyHandler);
+  var KeyboardShortcutBehaviorImpl = {
+    modifierPressed: function(e) {
+      e = getKeyboardEvent(e);
+      return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
     },
 
     shouldSuppressKeyboardShortcut: function(e) {
-      for (var c in KeyboardShortcutBehavior._disablers) {
-        if (KeyboardShortcutBehavior._disablers[c] === true) {
-          return true;
-        }
+      e = getKeyboardEvent(e);
+      if (e.path[0].tagName === 'INPUT' || e.path[0].tagName === 'TEXTAREA') {
+        return true;
       }
-      var getModifierState = e.getModifierState ?
-          e.getModifierState.bind(e) :
-          function() { return false; };
-      var target = e.detail ? e.detail.keyboardEvent : e.target;
-      return getModifierState('Control') ||
-             getModifierState('Alt') ||
-             getModifierState('Meta') ||
-             getModifierState('Fn') ||
-             target.tagName == 'INPUT' ||
-             target.tagName == 'TEXTAREA' ||
-             target.tagName == 'SELECT' ||
-             target.tagName == 'BUTTON' ||
-             target.tagName == 'A' ||
-             target.tagName == 'GR-BUTTON';
-    },
-
-    disable: function(id) {
-      KeyboardShortcutBehavior._disablers[id] = true;
-    },
-
-    enable: function(id) {
-      delete KeyboardShortcutBehavior._disablers[id];
+      for (var i = 0; i < e.path.length; i++) {
+        if (e.path[i].tagName === 'GR-OVERLAY') { return true; }
+      }
+      return false;
     },
   };
 
   window.Gerrit = window.Gerrit || {};
-  window.Gerrit.KeyboardShortcutBehavior = KeyboardShortcutBehavior;
+  /** @polymerBehavior Gerrit.KeyboardShortcutBehavior */
+  window.Gerrit.KeyboardShortcutBehavior = [
+    Polymer.IronA11yKeysBehavior,
+    KeyboardShortcutBehaviorImpl,
+  ];
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior_test.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior_test.html
index 5ec4145..457c5f25 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior_test.html
@@ -30,49 +30,76 @@
   </template>
 </test-fixture>
 
+<test-fixture id="within-overlay">
+  <template>
+    <gr-overlay>
+      <test-element></test-element>
+    </gr-overlay>
+  </template>
+</test-fixture>
+
 <script>
   suite('keyboard-shortcut-behavior tests', function() {
     var element;
+    var overlay;
 
     suiteSetup(function() {
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element',
         behaviors: [Gerrit.KeyboardShortcutBehavior],
-        properties: {
-          keyEventTarget: {
-            value: function() { return document.body; },
-          },
-          log: {
-            value: function() { return []; },
-          },
+        keyBindings: {
+          'k': '_handleKey'
         },
-
-        _handleKey: function(e) {
-          if (!this.shouldSuppressKeyboardShortcut(e)) {
-            this.log.push(e.keyCode);
-          }
-        },
+        _handleKey: function() {},
       });
     });
 
     setup(function() {
       element = fixture('basic');
+      overlay = fixture('within-overlay');
     });
 
-    test('blocks keydown events iff one or more disablers', function() {
-      MockInteractions.pressAndReleaseKeyOn(document.body, 97);  // 'a'
-      Gerrit.KeyboardShortcutBehavior.enable('x');  // should have no effect
-      MockInteractions.pressAndReleaseKeyOn(document.body, 98);  // 'b'
-      Gerrit.KeyboardShortcutBehavior.disable('x');  // blocking starts here
-      MockInteractions.pressAndReleaseKeyOn(document.body, 99);  // 'c'
-      Gerrit.KeyboardShortcutBehavior.disable('y');
-      MockInteractions.pressAndReleaseKeyOn(document.body, 100);  // 'd'
-      Gerrit.KeyboardShortcutBehavior.enable('x');
-      MockInteractions.pressAndReleaseKeyOn(document.body, 101);  // 'e'
-      Gerrit.KeyboardShortcutBehavior.enable('y');  // blocking ends here
-      MockInteractions.pressAndReleaseKeyOn(document.body, 102);  // 'f'
-      assert.deepEqual(element.log, [97, 98, 102]);
+    test('doesn’t block kb shortcuts for non-whitelisted els', function(done) {
+      var divEl = document.createElement('div');
+      element.appendChild(divEl);
+      element._handleKey = function(e) {
+        assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
+        done();
+      };
+      MockInteractions.keyDownOn(divEl, 75, null, 'k');
     });
+
+    test('blocks kb shortcuts for input els', function(done) {
+      var inputEl = document.createElement('input');
+      element.appendChild(inputEl);
+      element._handleKey = function(e) {
+        assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+        done();
+      };
+      MockInteractions.keyDownOn(inputEl, 75, null, 'k');
+    });
+
+    test('blocks kb shortcuts for textarea els', function(done) {
+      var textareaEl = document.createElement('textarea');
+      element.appendChild(textareaEl);
+      element._handleKey = function(e) {
+        assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+        done();
+      };
+      MockInteractions.keyDownOn(textareaEl, 75, null, 'k');
+    });
+
+    test('blocks kb shortcuts for anything in a gr-overlay', function(done) {
+      var divEl = document.createElement('div');
+      var element = overlay.querySelector('test-element');
+      element.appendChild(divEl);
+      element._handleKey = function(e) {
+        assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+        done();
+      };
+      MockInteractions.keyDownOn(divEl, 75, null, 'k');
+    });
+
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
index 2be0afc..51626cc 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
@@ -78,6 +78,12 @@
       Gerrit.RESTClientBehavior,
     ],
 
+    keyBindings: {
+      'j': '_handleJKey',
+      'k': '_handleKKey',
+      'o enter': '_handleEnterKey',
+    },
+
     attached: function() {
       this._loadPreferences();
     },
@@ -149,31 +155,37 @@
           account._account_id != change.owner._account_id;
     },
 
-    _handleKey: function(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-      if (this.groups == null) { return; }
+    _getAggregateGroupsLen: function(groups) {
+      groups = groups || [];
       var len = 0;
       this.groups.forEach(function(group) {
         len += group.length;
       });
-      switch (e.keyCode) {
-        case 74:  // 'j'
-          e.preventDefault();
-          if (this.selectedIndex == len - 1) { return; }
-          this.selectedIndex += 1;
-          break;
-        case 75:  // 'k'
-          e.preventDefault();
-          if (this.selectedIndex == 0) { return; }
-          this.selectedIndex -= 1;
-          break;
-        case 79:  // 'o'
-        case 13:  // 'enter'
-          e.preventDefault();
-          page.show(this._changeURLForIndex(this.selectedIndex));
-          break;
-      }
+      return len;
+    },
+
+    _handleJKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      var len = this._getAggregateGroupsLen(this.groups);
+      if (this.selectedIndex === len - 1) { return; }
+      this.selectedIndex += 1;
+    },
+
+    _handleKKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      if (this.selectedIndex === 0) { return; }
+      this.selectedIndex -= 1;
+    },
+
+    _handleEnterKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      page.show(this._changeURLForIndex(this.selectedIndex));
     },
 
     _changeURLForIndex: function(index) {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
index 718cfb5..33b4279 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
@@ -131,25 +131,25 @@
 
       flush(function() {
         assert.isTrue(elementItems[0].selected);
-        MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'j'
+        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
         assert.equal(element.selectedIndex, 1);
-        MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'j'
+        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
 
         var showStub = sinon.stub(page, 'show');
         assert.equal(element.selectedIndex, 2);
-        MockInteractions.pressAndReleaseKeyOn(element, 13);  // 'enter'
+        MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
         assert(showStub.lastCall.calledWithExactly('/c/2/'),
             'Should navigate to /c/2/');
 
-        MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'k'
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
         assert.equal(element.selectedIndex, 1);
-        MockInteractions.pressAndReleaseKeyOn(element, 13);  // 'enter'
+        MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
         assert(showStub.lastCall.calledWithExactly('/c/1/'),
             'Should navigate to /c/1/');
 
-        MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'k'
-        MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'k'
-        MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'k'
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
         assert.equal(element.selectedIndex, 0);
 
         showStub.restore();
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html
index 588fff5..43714f2 100644
--- a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html
+++ b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html
@@ -42,7 +42,9 @@
           account="[[account]]"
           class$="[[_computeChipClass(account)]]"
           data-account-id$="[[account._account_id]]"
-          removable="[[_computeRemovable(account)]]">
+          removable="[[_computeRemovable(account)]]"
+          on-keydown="_handleChipKeydown"
+          tabindex$="[[index]]">
       </gr-account-chip>
     </template>
     <gr-account-entry
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
index 2e3200f..3b17756 100644
--- a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
+++ b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
@@ -90,6 +90,12 @@
 
     _handleRemove: function(e) {
       var toRemove = e.detail.account;
+      this._removeAccount(toRemove);
+      this.$.entry.focus();
+    },
+
+    _removeAccount: function(toRemove) {
+      if (!toRemove || !this._computeRemovable(toRemove)) { return; }
       for (var i = 0; i < this.accounts.length; i++) {
         var matches;
         var account = this.accounts[i];
@@ -100,12 +106,10 @@
         }
         if (matches) {
           this.splice('accounts', i, 1);
-          this.$.entry.focus();
           return;
         }
       }
-      console.warn('received remove event for missing account',
-          e.detail.account);
+      console.warn('received remove event for missing account', toRemove);
     },
 
     _handleInputKeydown: function(e) {
@@ -116,7 +120,51 @@
       }
       switch (e.detail.keyCode) {
         case 8: // Backspace
-          this.splice('accounts', this.accounts.length - 1, 1);
+          this._removeAccount(this.accounts[this.accounts.length - 1]);
+          break;
+        case 37: // Left arrow
+          var chips = this.accountChips;
+          if (chips[chips.length - 1]) {
+            chips[chips.length - 1].focus();
+          }
+          break;
+      }
+    },
+
+    _handleChipKeydown: function(e) {
+      var chip = e.target;
+      var chips = this.accountChips;
+      var index = chips.indexOf(chip);
+      switch (e.keyCode) {
+        case 8: // Backspace
+        case 13: // Enter
+        case 32: // Spacebar
+        case 46: // Delete
+          this._removeAccount(chip.account);
+          // Splice from this array to avoid inconsistent ordering of
+          // event handling.
+          chips.splice(index, 1);
+          if (index < chips.length) {
+            chips[index].focus();
+          } else if (index > 0) {
+            chips[index - 1].focus();
+          } else {
+            this.$.entry.focus();
+          }
+          break;
+        case 37: // Left arrow
+          if (index > 0) {
+            chip.blur();
+            chips[index - 1].focus();
+          }
+          break;
+        case 39: // Right arrow
+          chip.blur();
+          if (index < chips.length - 1) {
+            chips[index + 1].focus();
+          } else {
+            this.$.entry.focus();
+          }
           break;
       }
     },
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
index cbfe1ff..1bd12b4 100644
--- a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
@@ -48,6 +48,7 @@
 
     var existingReviewer1;
     var existingReviewer2;
+    var sandbox;
     var element;
 
     function getChips() {
@@ -55,6 +56,7 @@
     }
 
     setup(function() {
+      sandbox = sinon.sandbox.create();
       existingReviewer1 = makeAccount();
       existingReviewer2 = makeAccount();
 
@@ -65,6 +67,10 @@
       element.accounts = [existingReviewer1, existingReviewer2];
     });
 
+    teardown(function() {
+      sandbox.restore();
+    });
+
     test('account entry only appears when editable', function() {
       element.readonly = false;
       assert.isFalse(element.$.entry.hasAttribute('hidden'));
@@ -74,7 +80,7 @@
 
     test('addition and removal of account/group chips', function() {
       flushAsynchronousOperations();
-
+      sandbox.stub(element, '_computeRemovable').returns(true);
       // Existing accounts are listed.
       var chips = getChips();
       assert.equal(chips.length, 2);
@@ -229,30 +235,68 @@
       ]);
     });
 
+    test('removeAccount fails if account is not removable', function() {
+      element.readonly = true;
+      var acct = makeAccount();
+      element.accounts = [acct];
+      element._removeAccount(acct);
+      assert.equal(element.accounts.length, 1);
+    });
+
     suite('keyboard interactions', function() {
-      var sandbox;
-      setup(function() {
-        sandbox = sinon.sandbox.create();
-      });
 
-      teardown(function() {
-        sandbox.restore();
-      });
-
-      test('backspace from input removes account iff cursor is in start pos',
-          function(done) {
+      test('backspace at text input start removes last account', function() {
         var input = element.$.entry.$.input;
         sandbox.stub(element.$.entry, '_getReviewerSuggestions');
         sandbox.stub(input, '_updateSuggestions');
+        sandbox.stub(element, '_computeRemovable').returns(true);
+        // Next line is a workaround for Firefix not moving cursor
+        // on input field update
+        assert.equal(input.$.input.selectionStart, 0);
         input.text = 'test';
         MockInteractions.focus(input.$.input);
+        flushAsynchronousOperations();
+        assert.equal(element.accounts.length, 2);
+        MockInteractions.pressAndReleaseKeyOn(input.$.input, 8); // Backspace
+        assert.equal(element.accounts.length, 2);
+        input.text = '';
+        MockInteractions.pressAndReleaseKeyOn(input.$.input, 8); // Backspace
+        assert.equal(element.accounts.length, 1);
+      });
+
+      test('arrow key navigation', function() {
+        var input = element.$.entry.$.input;
+        input.text = '';
+        element.accounts = [makeAccount(), makeAccount()];
+        MockInteractions.focus(input.$.input);
+        flushAsynchronousOperations();
+        var chips = element.accountChips;
+        var chipsOneSpy = sandbox.spy(chips[1], 'focus');
+        MockInteractions.pressAndReleaseKeyOn(input.$.input, 37); // Left
+        assert.isTrue(chipsOneSpy.called);
+        var chipsZeroSpy = sandbox.spy(chips[0], 'focus');
+        MockInteractions.pressAndReleaseKeyOn(chips[1], 37); // Left
+        assert.isTrue(chipsZeroSpy.called);
+        MockInteractions.pressAndReleaseKeyOn(chips[0], 37); // Left
+        assert.isTrue(chipsZeroSpy.calledOnce);
+        MockInteractions.pressAndReleaseKeyOn(chips[0], 39); // Right
+        assert.isTrue(chipsOneSpy.calledTwice);
+      });
+
+      test('delete', function(done) {
+        element.accounts = [makeAccount(), makeAccount()];
         flush(function() {
-          assert.equal(element.accounts.length, 2);
-          MockInteractions.pressAndReleaseKeyOn(input.$.input, 8); // Backspace
-          assert.equal(element.accounts.length, 2);
-          input.text = '';
-          MockInteractions.pressAndReleaseKeyOn(input.$.input, 8); // Backspace
-          assert.equal(element.accounts.length, 1);
+          var chips = element.accountChips;
+          var focusSpy = sandbox.spy(element.accountChips[1], 'focus');
+          var removeSpy = sandbox.spy(element, '_removeAccount');
+          MockInteractions.pressAndReleaseKeyOn(
+              element.accountChips[0], 8); // Backspace
+          assert.isTrue(focusSpy.called);
+          assert.isTrue(removeSpy.calledOnce);
+
+          MockInteractions.pressAndReleaseKeyOn(
+              element.accountChips[1], 46); // Delete
+          assert.isTrue(removeSpy.calledTwice);
           done();
         });
       });
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
index 30e9e86..9c591db 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
@@ -76,7 +76,7 @@
               on-tap="_handleActionTap"></gr-button>
         </template>
       </section>
-      <section hidden$="[[!_actionCount(_revisionActions.*, _additionalActions.*)]]">
+      <section hidden$="[[!_actionCount(revisionActions.*, _additionalActions.*)]]">
         <template is="dom-repeat" items="[[_revisionActionValues]]" as="action">
           <gr-button title$="[[action.title]]"
               hidden$="[[_computeActionHidden(action.__key, _hiddenRevisionActions.*)]]"
@@ -98,7 +98,9 @@
           hidden></gr-confirm-rebase-dialog>
       <gr-confirm-cherrypick-dialog id="confirmCherrypick"
           class="confirmDialog"
-          message="[[commitMessage]]"
+          change-status="[[changeStatus]]"
+          commit-message="[[commitMessage]]"
+          commit-num="[[commitNum]]"
           on-confirm="_handleCherrypickConfirm"
           on-cancel="_handleConfirmDialogCancel"
           hidden></gr-confirm-cherrypick-dialog>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
index ef2a3b4..ccfb124 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
@@ -49,6 +49,15 @@
 
   var ADDITIONAL_ACTION_KEY_PREFIX = '__additionalAction_';
 
+  var QUICK_APPROVE_ACTION = {
+    __key: 'review',
+    __type: 'change',
+    enabled: true,
+    key: 'review',
+    label: 'Quick Approve',
+    method: 'POST',
+  };
+
   Polymer({
     is: 'gr-change-actions',
 
@@ -74,29 +83,31 @@
         },
       },
       changeNum: String,
+      changeStatus: String,
+      commitNum: String,
       patchNum: String,
       commitMessage: {
         type: String,
         value: '',
       },
+      revisionActions: {
+        type: Object,
+        value: function() { return {}; },
+      },
 
       _loading: {
         type: Boolean,
         value: true,
       },
-      _revisionActions: {
-        type: Object,
-        value: function() { return {}; },
-      },
       _revisionActionValues: {
         type: Array,
-        computed: '_computeRevisionActionValues(_revisionActions.*, ' +
+        computed: '_computeRevisionActionValues(revisionActions.*, ' +
             'primaryActionKeys.*, _additionalActions.*)',
       },
       _changeActionValues: {
         type: Array,
         computed: '_computeChangeActionValues(actions.*, ' +
-            'primaryActionKeys.*, _additionalActions.*)',
+            'primaryActionKeys.*, _additionalActions.*, change)',
       },
       _additionalActions: {
         type: Array,
@@ -121,7 +132,7 @@
     ],
 
     observers: [
-      '_actionsChanged(actions.*, _revisionActions.*, _additionalActions.*)',
+      '_actionsChanged(actions.*, revisionActions.*, _additionalActions.*)',
     ],
 
     ready: function() {
@@ -137,7 +148,7 @@
       return this._getRevisionActions().then(function(revisionActions) {
         if (!revisionActions) { return; }
 
-        this._revisionActions = revisionActions;
+        this.revisionActions = revisionActions;
         this._loading = false;
       }.bind(this)).catch(function(err) {
         alert('Couldn’t load revision actions. Check the console ' +
@@ -241,9 +252,86 @@
     },
 
     _computeChangeActionValues: function(actionsChangeRecord,
-        primariesChangeRecord, additionalActionsChangeRecord) {
-      return this._getActionValues(actionsChangeRecord, primariesChangeRecord,
-          additionalActionsChangeRecord, ActionType.CHANGE);
+        primariesChangeRecord, additionalActionsChangeRecord, change) {
+      var actions = this._getActionValues(
+        actionsChangeRecord, primariesChangeRecord,
+        additionalActionsChangeRecord, ActionType.CHANGE, change);
+      var quickApprove = this._getQuickApproveAction();
+      if (quickApprove) {
+        actions.unshift(quickApprove);
+      }
+      return actions;
+    },
+
+    _getMaxScoreTextForLabel: function(label) {
+      if (!this.change ||
+          !this.change.permitted_labels ||
+          !this.change.permitted_labels[label] ||
+          !this.change.permitted_labels[label].length) {
+        return null;
+      }
+      return this.change.permitted_labels[label].slice(-1)[0];
+    },
+
+    _getMaxScoreForLabel: function(label) {
+      return parseInt(this._getMaxScoreTextForLabel(label), 10);
+    },
+
+    /**
+     * Get highest score for missing permitted label for current change.
+     *
+     * @return {{label: string, score: string}}
+     */
+    _getTopMissingApproval: function() {
+      var change = this.change;
+      if (!change || !change.labels || !change.permitted_labels) {
+        return null;
+      }
+
+      // Use only labels that satisfy all of following:
+      // - label scoring is permitted.
+      // - label is not approved yet.
+      // - label score is less than max permitted.
+      var missingApprovals = Object.keys(change.labels)
+          .filter(function(label) {
+            return label in change.permitted_labels &&
+                !change.labels[label].approved &&
+                (change.labels[label].value == null ||
+                  change.labels[label].value <
+                    this._getMaxScoreForLabel(label));
+          }.bind(this))
+          .sort(function(a, b) {
+            // Sort descending by max permitted score.
+            return this._getMaxScoreForLabel(b) - this._getMaxScoreForLabel(a);
+          }.bind(this));
+      if (!missingApprovals.length) {
+        return null;
+      }
+      var score = this._getMaxScoreForLabel(missingApprovals[0]);
+      // Guard against votes that fail to parse as integers. (Shouldn't happen.)
+      if (isNaN(score) || score <= 0) {
+        return null;
+      }
+      return {
+        label: missingApprovals[0],
+        score: this._getMaxScoreTextForLabel(missingApprovals[0]),
+      };
+    },
+
+    _getQuickApproveAction: function() {
+      var approval = this._getTopMissingApproval();
+      if (!approval) {
+        return null;
+      }
+      var action = Object.assign({}, QUICK_APPROVE_ACTION);
+      action.label = approval.label + approval.score;
+      var review = {
+        drafts: 'PUBLISH_ALL_REVISIONS',
+        labels: {},
+      };
+      review.labels[approval.label] = approval.score;
+      action.payload = review;
+      return action;
     },
 
     _getActionValues: function(actionsChangeRecord, primariesChangeRecord,
@@ -342,6 +430,12 @@
         this.showRevertDialog();
       } else if (key === ChangeActions.ABANDON) {
         this._showActionDialog(this.$.confirmAbandonDialog);
+      } else if (key === QUICK_APPROVE_ACTION.key) {
+        var action = this._changeActionValues.find(function(o) {
+          return o.key === key;
+        });
+        this._fireAction(
+          this._prependSlash(key), action, true, action.payload);
       } else {
         this._fireAction(this._prependSlash(key), this.actions[key], false);
       }
@@ -363,7 +457,7 @@
           /* falls through */ // required by JSHint
         default:
           this._fireAction(this._prependSlash(key),
-              this._revisionActions[key], true);
+              this.revisionActions[key], true);
       }
     },
 
@@ -400,7 +494,7 @@
       }
       this.$.overlay.close();
       el.hidden = true;
-      this._fireAction('/rebase', this._revisionActions.rebase, true, payload);
+      this._fireAction('/rebase', this.revisionActions.rebase, true, payload);
     },
 
     _handleCherrypickConfirm: function() {
@@ -418,7 +512,7 @@
       el.hidden = true;
       this._fireAction(
           '/cherrypick',
-          this._revisionActions.cherrypick,
+          this.revisionActions.cherrypick,
           true,
           {
             destination: el.branch,
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
index 00e61d9..9643e93 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
@@ -84,6 +84,7 @@
       });
 
       element = fixture('basic');
+      element.change = {};
       element.changeNum = '42';
       element.patchNum = '2';
       element.actions = {
@@ -320,9 +321,16 @@
         element._handleCherrypickConfirm();
         assert.equal(fireActionStub.callCount, 0);  // Still needs a message.
 
-        element.$.confirmCherrypick.message = 'foo message';
+        // Add attributes that are used to determine the message.
+        element.$.confirmCherrypick.commitMessage = 'foo message';
+        element.$.confirmCherrypick.changeStatus = 'OPEN';
+        element.$.confirmCherrypick.commitNum = '123';
+
         element._handleCherrypickConfirm();
 
+        assert.equal(element.$.confirmCherrypick.$.messageInput.value,
+            'foo message');
+
         assert.deepEqual(fireActionStub.lastCall.args, [
           '/cherrypick', action, true, {
             destination: 'master',
@@ -488,5 +496,141 @@
         assert.isFalse(fireActionStub.called);
       });
     });
+
+    suite('quick approve', function() {
+      setup(function() {
+        element.change = {
+          current_revision: 'abc1234',
+        };
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            foo: {},
+          },
+          permitted_labels: {
+            foo: ['-1', '0', '+1'],
+          },
+        };
+        flushAsynchronousOperations();
+      });
+
+      test('added when can approve', function() {
+        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+        assert.isNotNull(approveButton);
+      });
+
+      test('is first in list of actions', function() {
+        var approveButton = element.$$('gr-button');
+        assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
+      });
+
+      test('not added when already approved', function() {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            foo: {
+              approved: {},
+            },
+          },
+          permitted_labels: {
+            foo: [],
+          },
+        };
+        flushAsynchronousOperations();
+        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+        assert.isNull(approveButton);
+      });
+
+      test('not added when label not permitted', function() {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            foo: {},
+          },
+          permitted_labels: {
+            bar: [],
+          },
+        };
+        flushAsynchronousOperations();
+        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+        assert.isNull(approveButton);
+      });
+
+      test('approves when taped', function() {
+        var fireActionStub = sinon.stub(element, '_fireAction');
+        MockInteractions.tap(
+            element.$$('gr-button[data-action-key=\'review\']'));
+        flushAsynchronousOperations();
+        assert.isTrue(fireActionStub.called);
+        assert.isTrue(fireActionStub.calledWith('/review'));
+        var payload = fireActionStub.lastCall.args[3];
+        assert.deepEqual(payload.labels, {foo: '+1'});
+        fireActionStub.restore();
+      });
+
+      test('higher permitted score has priority', function() {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            foo: {},
+            bar: {},
+          },
+          permitted_labels: {
+            foo: [' 0', '+1'],
+            bar: [' 0', '+1', '+2'],
+          },
+        };
+        flushAsynchronousOperations();
+        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+        assert.equal(approveButton.getAttribute('data-label'), 'bar+2');
+      });
+
+      test('button label for missing approval', function() {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            foo: {},
+            bar: {approved: {}},
+          },
+          permitted_labels: {
+            foo: [' 0', '+1'],
+            bar: [' 0', '+1', '+2'],
+          },
+        };
+        flushAsynchronousOperations();
+        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+        assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
+      });
+
+      test('non-approving score', function() {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            bar: {value: 1},
+          },
+          permitted_labels: {
+            bar: [' 0', '+1'],
+          },
+        };
+        flushAsynchronousOperations();
+        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+        assert.isNull(approveButton);
+      });
+
+      test('approving label with a non-max score', function() {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            bar: {value: 1},
+          },
+          permitted_labels: {
+            bar: [' 0', '+1', '+2'],
+          },
+        };
+        flushAsynchronousOperations();
+        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+        assert.equal(approveButton.getAttribute('data-label'), 'bar+2');
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
index 83e182a..ce17b3a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
@@ -20,6 +20,7 @@
 <link rel="import" href="../../shared/gr-label/gr-label.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
+<link rel="import" href="../../shared/gr-linked-chip/gr-linked-chip.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-reviewer-list/gr-reviewer-list.html">
 
@@ -145,11 +146,20 @@
     <section>
       <span class="title">Topic</span>
       <span class="value">
-        <gr-editable-label
-            value="{{change.topic}}"
-            placeholder="[[_computeTopicPlaceholder(_topicReadOnly)]]"
-            read-only="[[_topicReadOnly]]"
-            on-changed="_handleTopicChanged"></gr-editable-label>
+        <template is="dom-if" if="[[change.topic]]">
+          <gr-linked-chip
+              text="[[change.topic]]"
+              href="[[_computeTopicHref(change.topic)]]"
+              removable
+              on-remove="_handleTopicRemoved"></gr-linked-chip>
+        </template>
+        <template is="dom-if" if="[[!change.topic]]">
+          <gr-editable-label
+              value="{{change.topic}}"
+              placeholder="[[_computeTopicPlaceholder(_topicReadOnly)]]"
+              read-only="[[_topicReadOnly]]"
+              on-changed="_handleTopicChanged"></gr-editable-label>
+        </template>
       </span>
     </section>
     <section class="strategy" hidden$="[[_computeHideStrategy(change)]]" hidden>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
index 01ef473..b56324a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
@@ -172,5 +172,15 @@
       }
       return output;
     },
+
+    _computeTopicHref: function(topic) {
+      return '/q/topic:' + encodeURIComponent(encodeURIComponent(topic)) +
+          '+(status:open OR status:merged)';
+    },
+
+    _handleTopicRemoved: function() {
+      this.set(['change', 'topic'], '');
+      this.$.restAPI.setChangeTopic(this.change.change_id, null);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
index 7fa4744..d354fd7 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
@@ -173,6 +173,15 @@
         element._handleTopicChanged({}, 'the new topic');
         assert.isTrue(topicStub.calledWith('the id', 'the new topic'));
       });
+
+      test('clicking x on topic chip removes topic', function() {
+        var topicStub = sandbox.stub(element.$.restAPI, 'setChangeTopic');
+        flushAsynchronousOperations();
+        var remove = element.$$('gr-linked-chip').$.remove;
+        MockInteractions.tap(remove);
+        assert.equal(element.change.topic, '');
+        assert.isTrue(topicStub.called);
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
index 1c703fd..a1b7852 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
@@ -15,6 +15,7 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior.html">
 <link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
@@ -95,7 +96,7 @@
       }
       /* Prevent plugin text from overflowing. */
       #change_plugins {
-        word-break: break-all;
+        word-break: break-word;
       }
       .commitMessage {
         font-family: var(--monospace-font-family);
@@ -159,6 +160,9 @@
       .latestPatchContainer {
         display: none;
       }
+      .patchSetSelect {
+        max-width: 25em;
+      }
       @media screen and (max-width: 50em) {
         .header {
           align-items: flex-start;
@@ -173,7 +177,7 @@
         }
         gr-reply-dialog {
           min-width: initial;
-          width: 90vw;
+          width: 100vw;
         }
         .downloadContainer {
           display: none;
@@ -209,6 +213,9 @@
           flex: initial;
           margin-right: 0;
         }
+        .scrollable {
+          @apply(--layout-scroll);
+        }
       }
     </style>
     <div class="container loading" hidden$="[[!_loading]]">Loading...</div>
@@ -218,8 +225,8 @@
           <gr-change-star change="{{_change}}" hidden$="[[!_loggedIn]]"></gr-change-star>
           <a href$="[[_computeChangePermalink(_change._number)]]">[[_change._number]]</a><!--
        --><span class="changeStatus">[[_computeChangeStatus(_change, _patchRange.patchNum)]]</span><!--
-       --><span>:</span>
-          <span>[[_change.subject]]</span>
+       -->:
+          [[_change.subject]]
         </span>
       </div>
       <section class="changeInfo">
@@ -244,7 +251,10 @@
             <gr-change-actions id="actions"
                 change="[[_change]]"
                 actions="[[_change.actions]]"
+                revision-actions="[[_currentRevisionActions]]"
                 change-num="[[_changeNum]]"
+                change-status="[[_change.status]]"
+                commit-num="[[_commitInfo.commit]]"
                 patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
                 commit-message="[[_latestCommitMessage]]"
                 on-reload-change="_handleReloadChange"></gr-change-actions>
@@ -279,14 +289,19 @@
             <label class="patchSelectLabel" for="patchSetSelect">
               Patch set
             </label>
-            <select id="patchSetSelect" bind-value="{{_selectedPatchSet}}"
-                is="gr-select" on-change="_handlePatchChange">
+            <select
+                is="gr-select"
+                id="patchSetSelect"
+                bind-value="{{_selectedPatchSet}}"
+                class="patchSetSelect"
+                on-change="_handlePatchChange">
               <template is="dom-repeat" items="[[_allPatchSets]]"
                   as="patchNumber">
                 <option value$="[[patchNumber]]">
-                  <span>[[patchNumber]]</span>
+                  [[patchNumber]]
                   /
-                  <span>[[_computeLatestPatchNum(_allPatchSets)]]</span>
+                  [[_computeLatestPatchNum(_allPatchSets)]]
+                  [[_computePatchSetDescription(_change, patchNumber)]]
                 </option>
               </template>
             </select>
@@ -313,7 +328,7 @@
             comments="[[_comments]]"
             drafts="[[_diffDrafts]]"
             revisions="[[_change.revisions]]"
-            projectConfig="[[_projectConfig]]"
+            project-config="[[_projectConfig]]"
             selected-index="{{viewState.selectedFileIndex}}"
             diff-view-mode="{{viewState.diffMode}}"></gr-file-list>
       </section>
@@ -336,6 +351,7 @@
           on-close="_handleDownloadDialogClose"></gr-download-dialog>
     </gr-overlay>
     <gr-overlay id="replyOverlay"
+        class="scrollable"
         no-cancel-on-outside-click
         on-iron-overlay-opened="_handleReplyOverlayOpen"
         with-backdrop>
@@ -345,6 +361,7 @@
           permitted-labels="[[_change.permitted_labels]]"
           diff-drafts="[[_diffDrafts]]"
           server-config="[[serverConfig]]"
+          project-config="[[_projectConfig]]"
           on-send="_handleReplySent"
           on-cancel="_handleReplyCancel"
           on-autogrow="_handleReplyAutogrow"
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index bac25be..688cd0a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -55,6 +55,7 @@
         observer: '_changeChanged',
       },
       _commitInfo: Object,
+      _files: Object,
       _changeNum: String,
       _diffDrafts: {
         type: Object,
@@ -77,6 +78,7 @@
         type: Object,
         observer: '_updateSelected',
       },
+      _currentRevisionActions: Object,
       _allPatchSets: {
         type: Array,
         computed: '_computeAllPatchSets(_change)',
@@ -101,6 +103,7 @@
 
     behaviors: [
       Gerrit.KeyboardShortcutBehavior,
+      Gerrit.PatchSetBehavior,
       Gerrit.RESTClientBehavior,
     ],
 
@@ -109,6 +112,13 @@
       '_paramsAndChangeChanged(params, _change)',
     ],
 
+    keyBindings: {
+      'shift+r': '_handleCapitalRKey',
+      'a': '_handleAKey',
+      'd': '_handleDKey',
+      'u': '_handleUKey',
+    },
+
     attached: function() {
       this._getLoggedIn().then(function(loggedIn) {
         this._loggedIn = loggedIn;
@@ -319,6 +329,7 @@
       }
 
       var patchChanged = this._patchRange &&
+          (value.patchNum !== undefined && value.basePatchNum !== undefined) &&
           (this._patchRange.patchNum !== value.patchNum ||
           this._patchRange.basePatchNum !== value.basePatchNum);
 
@@ -494,7 +505,7 @@
     _computeChangeStatus: function(change, patchNum) {
       var statusString;
       if (change.status === this.ChangeStatus.NEW) {
-        var rev = this._getRevisionNumber(change, patchNum);
+        var rev = this.getRevisionNumber(change.revisions, patchNum);
         if (rev && rev.draft === true) {
           statusString = 'Draft';
         }
@@ -526,14 +537,6 @@
       });
     },
 
-    _getRevisionNumber: function(change, patchNum) {
-      for (var rev in change.revisions) {
-        if (change.revisions[rev]._number == patchNum) {
-          return change.revisions[rev];
-        }
-      }
-    },
-
     _computeLabelNames: function(labels) {
       return Object.keys(labels).sort();
     },
@@ -585,30 +588,29 @@
       }.bind(this));
     },
 
-    _handleKey: function(e) {
+    _handleAKey: function(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-      switch (e.keyCode) {
-        case 65:  // 'a'
-          if (this._loggedIn && !e.shiftKey) {
-            e.preventDefault();
-            this._openReplyDialog();
-          }
-          break;
-        case 68: // 'd'
-          e.preventDefault();
-          this.$.downloadOverlay.open();
-          break;
-        case 82: // 'r'
-          if (e.shiftKey) {
-            e.preventDefault();
-            this._switchToMostRecentPatchNum();
-          }
-          break;
-        case 85:  // 'u'
-          e.preventDefault();
-          this._determinePageBack();
-          break;
-      }
+      if (!this._loggedIn || e.detail.keyboardEvent.shiftKey) { return; }
+      e.preventDefault();
+      this._openReplyDialog();
+    },
+
+    _handleDKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+      e.preventDefault();
+      this.$.downloadOverlay.open();
+    },
+
+    _handleCapitalRKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+      e.preventDefault();
+      this._switchToMostRecentPatchNum();
+    },
+
+    _handleUKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+      e.preventDefault();
+      this._determinePageBack();
     },
 
     _determinePageBack: function() {
@@ -687,7 +689,25 @@
                 if (!change.reviewer_updates) {
                   change.reviewer_updates = null;
                 }
+                var latestRevisionSha = this._getLatestRevisionSHA(change);
+                var currentRevision = change.revisions[latestRevisionSha];
+                if (currentRevision.commit && currentRevision.commit.message) {
+                  this._latestCommitMessage = currentRevision.commit.message;
+                } else {
+                  this._latestCommitMessage = null;
+                }
+
                 this._change = change;
+                if (!this._patchRange || !this._patchRange.patchNum ||
+                    this._patchRange.patchNum === currentRevision._number) {
+                  // CommitInfo.commit is optional, and may need patching.
+                  if (!currentRevision.commit.commit) {
+                    currentRevision.commit.commit = latestRevisionSha;
+                  }
+                  this._commitInfo = currentRevision.commit;
+                  this._currentRevisionActions = currentRevision.actions;
+                  // TODO: Fetch and process files.
+                }
               }.bind(this));
     },
 
@@ -706,6 +726,25 @@
               }.bind(this));
     },
 
+    _getLatestRevisionSHA: function(change) {
+      if (change.current_revision) {
+        return change.current_revision;
+      }
+      // current_revision may not be present in the case where the latest rev is
+      // a draft and the user doesn’t have permission to view that rev.
+      var latestRev = null;
+      var latestPatchNum = -1;
+      for (var rev in change.revisions) {
+        if (!change.revisions.hasOwnProperty(rev)) { continue; }
+
+        if (change.revisions[rev]._number > latestPatchNum) {
+          latestRev = rev;
+          latestPatchNum = change.revisions[rev]._number;
+        }
+      }
+      return latestRev;
+    },
+
     _getCommitInfo: function() {
       return this.$.restAPI.getChangeCommitInfo(
           this._changeNum, this._patchRange.patchNum).then(
@@ -734,41 +773,29 @@
 
       var detailCompletes = this._getChangeDetail().then(function() {
         this._loading = false;
+        this._getProjectConfig();
       }.bind(this));
       this._getComments();
 
       if (this._patchRange.patchNum) {
-        return this._reloadPatchNumDependentResources().then(function() {
-          return detailCompletes;
-        }).then(function() {
-          return this._reloadDetailDependentResources();
+        return Promise.all([
+          this._reloadPatchNumDependentResources(),
+          detailCompletes,
+        ]).then(function() {
+          return this.$.actions.reload();
         }.bind(this));
       } else {
         // The patch number is reliant on the change detail request.
         return detailCompletes.then(function() {
-          return this._reloadPatchNumDependentResources();
-        }.bind(this)).then(function() {
-          return this._reloadDetailDependentResources();
+          this.$.fileList.reload();
+          if (!this._latestCommitMessage) {
+            this._getLatestCommitMessage();
+          }
         }.bind(this));
       }
     },
 
     /**
-     * Kicks off requests for resources that rely on the change detail
-     * (`this._change`) being loaded.
-     */
-    _reloadDetailDependentResources: function() {
-      if (!this._change) { return Promise.resolve(); }
-
-      return this._getProjectConfig().then(function() {
-        return Promise.all([
-          this._getLatestCommitMessage(),
-          this.$.actions.reload(),
-        ]);
-      }.bind(this));
-    },
-
-    /**
      * Kicks off requests for resources that rely on the patch range
      * (`this._patchRange`) being defined.
      */
@@ -782,5 +809,10 @@
     _updateSelected: function() {
       this._selectedPatchSet = this._patchRange.patchNum;
     },
+
+    _computePatchSetDescription: function(change, patchNum) {
+      var rev = this.getRevisionNumber(change.revisions, patchNum);
+      return (rev && rev.description) ? rev.description : '';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
index e61d6b0..6b5849a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
@@ -53,27 +53,27 @@
     suite('keyboard shortcuts', function() {
       test('U should navigate to / if no backPage set', function() {
         var showStub = sandbox.stub(page, 'show');
-        MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'U'
+        MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
         assert(showStub.lastCall.calledWithExactly('/'));
       });
 
       test('U should navigate to backPage if set', function() {
         element.backPage = '/dashboard/self';
         var showStub = sandbox.stub(page, 'show');
-        MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'U'
+        MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
         assert(showStub.lastCall.calledWithExactly('/dashboard/self'));
       });
 
       test('A should toggle overlay', function() {
-        MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'A'
+        MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
         var overlayEl = element.$.replyOverlay;
         assert.isFalse(overlayEl.opened);
         element._loggedIn = true;
 
-        MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift');  // 'A'
+        MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
         assert.isFalse(overlayEl.opened);
 
-        MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'A'
+        MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
         assert.isTrue(overlayEl.opened);
         overlayEl.close();
         assert.isFalse(overlayEl.opened);
@@ -102,7 +102,7 @@
           return Promise.resolve({
             change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
             revisions: {
-              rev1: {_number: 1},
+              rev1: {_number: 1, commit: {}},
               rev13: {_number: 13},
             },
             current_revision: 'rev1',
@@ -117,13 +117,12 @@
           done();
         });
 
-        // 'shift + R'
-        MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift');
+        MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
       });
 
       test('d should open download overlay', function() {
         var stub = sandbox.stub(element.$.downloadOverlay, 'open');
-        MockInteractions.pressAndReleaseKeyOn(element, 68); // 'd'
+        MockInteractions.pressAndReleaseKeyOn(element, 68, null, 'd');
         assert.isTrue(stub.called);
       });
     });
@@ -378,6 +377,20 @@
 
     });
 
+    test('reload entire page when patchRange doesnt change', function() {
+      var reloadStub = sandbox.stub(element, '_reload',
+          function() { return Promise.resolve(); });
+
+      var value = {
+        view: 'gr-change-view',
+      };
+      element._paramsChanged(value);
+      assert.isTrue(reloadStub.calledOnce);
+      element._initialLoadComplete = true;
+      element._paramsChanged(value);
+      assert.isTrue(reloadStub.calledTwice);
+    });
+
     test('change status new', function() {
       element._changeNum = '1';
       element._patchRange = {
@@ -439,6 +452,23 @@
       assert.equal(status, ' (Draft)');
     });
 
+    test('get latest revision', function() {
+      var change = {
+        revisions: {
+          rev1: {_number: 1},
+          rev3: {_number: 3},
+        },
+        current_revision: 'rev3',
+      };
+      assert.equal(element._getLatestRevisionSHA(change), 'rev3');
+      change = {
+        revisions: {
+          rev1: {_number: 1},
+        },
+      };
+      assert.equal(element._getLatestRevisionSHA(change), 'rev1');
+    });
+
     test('show commit message edit button', function() {
       var _change = {
         status: element.ChangeStatus.MERGED,
@@ -454,7 +484,12 @@
     test('topic is coalesced to null', function(done) {
       sandbox.stub(element, '_changeChanged');
       sandbox.stub(element.$.restAPI, 'getChangeDetail', function() {
-        return Promise.resolve({id: '123456789', labels: {}});
+        return Promise.resolve({
+          id: '123456789',
+          labels: {},
+          current_revision: 'foo',
+          revisions: {foo: {commit: {}}},
+        });
       });
 
       element._getChangeDetail().then(function() {
@@ -463,6 +498,23 @@
       });
     });
 
+    test('commit sha is populated from getChangeDetail', function(done) {
+      sandbox.stub(element, '_changeChanged');
+      sandbox.stub(element.$.restAPI, 'getChangeDetail', function() {
+        return Promise.resolve({
+          id: '123456789',
+          labels: {},
+          current_revision: 'foo',
+          revisions: {foo: {commit: {}}},
+        });
+      });
+
+      element._getChangeDetail().then(function() {
+        assert.equal('foo', element._commitInfo.commit);
+        done();
+      });
+    });
+
     test('reply dialog focus can be controlled', function() {
       var FocusTarget = element.$.replyDialog.FocusTarget;
       var openSpy = sandbox.spy(element, '_openReplyDialog');
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
index 8102006..40dffcc 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
@@ -16,14 +16,13 @@
 
 <link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
+<link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
 
 <dom-module id="gr-comment-list">
   <template>
     <style>
       :host {
         display: block;
-        font-family: var(--monospace-font-family);
         word-wrap: break-word;
       }
       .file {
@@ -38,7 +37,8 @@
       }
       .lineNum {
         margin-right: .35em;
-        min-width: 7em;
+        min-width: 5em;
+        text-align: right;
       }
       .message {
         flex: 1;
@@ -62,10 +62,9 @@
                File comment:
              </span>
           </a>
-          <gr-linked-text class="message"
-              pre
+          <gr-formatted-text class="message"
               content="[[comment.message]]"
-              config="[[projectConfig.commentlinks]]"></gr-linked-text>
+              config="[[projectConfig.commentlinks]]"></gr-formatted-text>
         </div>
       </template>
     </template>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
index ea65c01..481b124 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
@@ -59,6 +59,7 @@
         <iron-autogrow-textarea
             id="messageInput"
             class="message"
+            autocomplete="on"
             placeholder="<Insert reasoning here>"
             bind-value="{{message}}"></iron-autogrow-textarea>
       </div>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
index ae738e5..ebc6533 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
@@ -71,6 +71,7 @@
         <iron-autogrow-textarea
             id="messageInput"
             class="message"
+            autocomplete="on"
             rows="4"
             max-rows="15"
             bind-value="{{message}}"></iron-autogrow-textarea>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
index f27e4e2..e6f60ad 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
@@ -31,7 +31,22 @@
 
     properties: {
       branch: String,
-      message: String,
+      changeStatus: String,
+      commitMessage: String,
+      commitNum: String,
+      message: {
+        type: String,
+        computed: '_computeMessage(changeStatus, commitNum, commitMessage)',
+      },
+    },
+
+    _computeMessage: function(changeStatus, commitNum, commitMessage) {
+      var newMessage = commitMessage;
+
+      if (changeStatus === 'MERGED') {
+        newMessage += '(cherry picked from commit ' + commitNum + ')';
+      }
+      return newMessage;
     },
 
     _handleConfirmTap: function(e) {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
new file mode 100644
index 0000000..edf7d7a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
@@ -0,0 +1,61 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-confirm-cherrypick-dialog</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-confirm-cherrypick-dialog.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-confirm-cherrypick-dialog></gr-confirm-cherrypick-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-confirm-cherrypick-dialog tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('with merged change', function() {
+      element.changeStatus = 'MERGED';
+      element.commitMessage = 'message\n';
+      element.commitNum = '123';
+      element.branch = 'master';
+      flushAsynchronousOperations();
+      var expectedMessage = 'message\n(cherry picked from commit 123)';
+      assert.equal(element._message, expectedMessage);
+    });
+
+    test('with unmerged change', function() {
+      element.changeStatus = 'OPEN';
+      element.commitMessage = 'message\n';
+      element.commitNum = '123';
+      element.branch = 'master';
+      flushAsynchronousOperations();
+      var expectedMessage = 'message\n';
+      assert.equal(element._message, expectedMessage);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
index b668d06..a38811f8 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
@@ -55,8 +55,9 @@
         </label>
         <iron-autogrow-textarea
             id="messageInput"
-            max-rows="15"
             class="message"
+            autocomplete="on"
+            max-rows="15"
             bind-value="{{message}}"></iron-autogrow-textarea>
       </div>
     </gr-confirm-dialog>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
index f11cca2..3142733 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
@@ -15,11 +15,13 @@
 -->
 
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../diff/gr-diff/gr-diff.html">
 <link rel="import" href="../../diff/gr-diff-cursor/gr-diff-cursor.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
 <link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-select/gr-select.html">
@@ -63,7 +65,7 @@
       .row:not(.header):hover {
         background-color: #f5fafd;
       }
-      .row[selected] {
+      .row.selected {
         background-color: #ebf5fb;
       }
       .path {
@@ -140,8 +142,11 @@
         display: block;
         margin: .25em 0 1em;
       }
+      .patchSetSelect {
+        max-width: 25em;
+      }
       @media screen and (max-width: 50em) {
-        .row[selected] {
+        .row.selected {
           background-color: transparent;
         }
         .stats {
@@ -175,8 +180,7 @@
         <select
             id="modeSelect"
             is="gr-select"
-            bind-value="{{diffViewMode}}"
-            on-change="_handleDropdownChange">
+            bind-value="{{diffViewMode}}">
           <option value="SIDE_BY_SIDE">Side By Side</option>
           <option value="UNIFIED_DIFF">Unified</option>
         </select>
@@ -184,15 +188,16 @@
         <label>
           Diff against
           <select id="patchChange" bind-value="{{_diffAgainst}}" is="gr-select"
-              on-change="_handlePatchChange">
+              class="patchSetSelect" on-change="_handlePatchChange">
             <option value="PARENT">Base</option>
-            <template 
-                is="dom-repeat" 
+            <template
+                is="dom-repeat"
                 items="[[_computePatchSets(revisions, patchRange.*)]]"
                 as="patchNum">
-              <option value$="[[patchNum]]" disabled$=
-                  "[[_computePatchSetDisabled(patchNum, patchRange.patchNum)]]">
+              <option value$="[[patchNum]]"
+                  disabled$="[[_computePatchSetDisabled(patchNum, patchRange.patchNum)]]">
                 [[patchNum]]
+                [[_computePatchSetDescription(revisions, patchNum)]]
               </option>
             </template>
           </select>
@@ -203,7 +208,7 @@
         items="[[_shownFiles]]"
         as="file"
         initial-count="[[_fileListIncrement]]">
-      <div class="row" selected$="[[_computeFileSelected(index, selectedIndex)]]">
+      <div class="file-row row" selected$="[[_computeFileSelected(index, selectedIndex)]]">
         <div class="reviewed" hidden$="[[!_loggedIn]]" hidden>
           <input type="checkbox" checked$="[[_computeReviewed(file, _reviewed)]]"
               data-path$="[[file.__path]]" on-change="_handleReviewedChange"
@@ -229,8 +234,17 @@
           [[_computeCommentsString(comments, patchRange.patchNum, file.__path)]]
         </div>
         <div class$="[[_computeClass('stats', file.__path)]]">
-          <span class="added">+[[file.lines_inserted]]</span>
-          <span class="removed">-[[file.lines_deleted]]</span>
+          <span class="added" hidden$=[[file.binary]]>
+            +[[file.lines_inserted]]
+          </span>
+          <span class="removed" hidden$=[[file.binary]]>
+            -[[file.lines_deleted]]
+          </span>
+          <span class$="[[_computeBinaryClass(file.size_delta)]]"
+              hidden$=[[!file.binary]]>
+            [[_formatBytes(file.size_delta)]]
+            [[_formatPercentage(file.size, file.size_delta)]]
+          </span>
         </div>
         <div class="show-hide">
           <label class="show-hide">
@@ -259,6 +273,20 @@
         <span class="removed">-[[_patchChange.deleted]]</span>
       </div>
     </div>
+    <div class="row totalChanges">
+      <div class="total-stats" hidden$="[[_hideBinaryChangeTotals]]">
+        <span class="added">
+          [[_formatBytes(_patchChange.size_delta_inserted)]]
+          [[_formatPercentage(_patchChange.total_size,
+              _patchChange.size_delta_inserted)]]
+        </span>
+        <span class="removed">
+          [[_formatBytes(_patchChange.size_delta_deleted)]]
+          [[_formatPercentage(_patchChange.total_size,
+              _patchChange.size_delta_deleted)]]
+        </span>
+      </div>
+    </div>
     <gr-button
         class="fileListButton"
         id="incrementButton"
@@ -275,7 +303,11 @@
     </gr-button>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-storage id="storage"></gr-storage>
-    <gr-diff-cursor id="cursor"></gr-diff-cursor>
+    <gr-diff-cursor id="diffCursor"></gr-diff-cursor>
+    <gr-cursor-manager
+        id="fileCursor"
+        scroll-behavior="keep-visible"
+        cursor-target-class="selected"></gr-cursor-manager>
   </template>
   <script src="gr-file-list.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index bf6dcf3..b423f50 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -35,10 +35,6 @@
       drafts: Object,
       revisions: Object,
       projectConfig: Object,
-      selectedIndex: {
-        type: Number,
-        notify: true,
-      },
       keyEventTarget: {
         type: Object,
         value: function() { return document.body; },
@@ -83,6 +79,10 @@
         type: Boolean,
         computed: '_shouldHideChangeTotals(_patchChange)',
       },
+      _hideBinaryChangeTotals: {
+        type: Boolean,
+        computed: '_shouldHideBinaryChangeTotals(_patchChange)',
+      },
       _shownFiles: {
         type: Array,
         computed: '_computeFilesShown(_numFilesShown, _files.*)',
@@ -102,9 +102,26 @@
 
     behaviors: [
       Gerrit.KeyboardShortcutBehavior,
+      Gerrit.PatchSetBehavior,
       Gerrit.URLEncodingBehavior,
     ],
 
+    keyBindings: {
+      'shift+left': '_handleShiftLeftKey',
+      'shift+right': '_handleShiftRightKey',
+      'i': '_handleIKey',
+      'shift+i': '_handleCapitalIKey',
+      'down j': '_handleDownKey',
+      'up k': '_handleUpKey',
+      'c': '_handleCKey',
+      '[': '_handleLeftBracketKey',
+      ']': '_handleRightBracketKey',
+      'o enter': '_handleEnterKey',
+      'n': '_handleNKey',
+      'p': '_handlePKey',
+      'shift+a': '_handleCapitalAKey',
+    },
+
     reload: function() {
       if (!this.changeNum || !this.patchRange.patchNum) {
         return Promise.resolve();
@@ -157,12 +174,21 @@
       return filesNoCommitMsg.reduce(function(acc, obj) {
         var inserted = obj.lines_inserted ? obj.lines_inserted : 0;
         var deleted = obj.lines_deleted ? obj.lines_deleted : 0;
+        var total_size = (obj.size && obj.binary) ? obj.size : 0;
+        var size_delta_inserted =
+            obj.binary && obj.size_delta > 0 ? obj.size_delta : 0;
+        var size_delta_deleted =
+            obj.binary && obj.size_delta < 0 ? obj.size_delta : 0;
 
         return {
           inserted: acc.inserted + inserted,
           deleted: acc.deleted + deleted,
+          size_delta_inserted: acc.size_delta_inserted + size_delta_inserted,
+          size_delta_deleted: acc.size_delta_deleted + size_delta_deleted,
+          total_size: acc.total_size + total_size,
         };
-      }, {inserted: 0, deleted: 0});
+      }, {inserted: 0, deleted: 0, size_delta_inserted: 0,
+        size_delta_deleted: 0, total_size: 0});
     },
 
     _getDiffPreferences: function() {
@@ -215,9 +241,6 @@
         this.set(['_shownFiles', i, '__expanded'], true);
         this.set(['_files', i, '__expanded'], true);
       }
-      if (e && e.target) {
-        e.target.blur();
-      }
     },
 
     _collapseAllDiffs: function(e) {
@@ -226,10 +249,7 @@
         this.set(['_shownFiles', i, '__expanded'], false);
         this.set(['_files', i, '__expanded'], false);
       }
-      this.$.cursor.handleDiffUpdate();
-      if (e && e.target) {
-        e.target.blur();
-      }
+      this.$.diffCursor.handleDiffUpdate();
     },
 
     _computeCommentsString: function(comments, patchNum, path) {
@@ -297,113 +317,133 @@
           });
     },
 
-    _handleKey: function(e) {
+    _handleShiftLeftKey: function(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-      switch (e.keyCode) {
-        case 37: // left
-          if (e.shiftKey && this._showInlineDiffs) {
-            e.preventDefault();
-            this.$.cursor.moveLeft();
-          }
-          break;
-        case 39: // right
-          if (e.shiftKey && this._showInlineDiffs) {
-            e.preventDefault();
-            this.$.cursor.moveRight();
-          }
-          break;
-        case 73:  // 'i'
-          if (e.shiftKey) {
-            e.preventDefault();
-            this._toggleInlineDiffs();
-          } else if (this.selectedIndex !== undefined) {
-            e.preventDefault();
-            var expanded = this._files[this.selectedIndex].__expanded;
-            // Until Polymer 2.0, manual management of reflection between _files
-            // and _shownFiles is necessary.
-            this.set(['_shownFiles', this.selectedIndex, '__expanded'],
-                !expanded);
-            this.set(['_files', this.selectedIndex, '__expanded'], !expanded);
-          }
-          break;
-        case 40:  // down
-        case 74:  // 'j'
-          e.preventDefault();
-          if (this._showInlineDiffs) {
-            this.$.cursor.moveDown();
-          } else {
-            this.selectedIndex =
-                Math.min(this._numFilesShown, this.selectedIndex + 1);
-            this._scrollToSelectedFile();
-          }
-          break;
-        case 38:  // up
-        case 75:  // 'k'
-          e.preventDefault();
-          if (this._showInlineDiffs) {
-            this.$.cursor.moveUp();
-          } else {
-            this.selectedIndex = Math.max(0, this.selectedIndex - 1);
-            this._scrollToSelectedFile();
-          }
-          break;
-        case 67: // 'c'
-          var isRangeSelected = this.diffs.some(function(diff) {
-            return diff.isRangeSelected();
-          }, this);
-          if (this._showInlineDiffs && !isRangeSelected) {
-            e.preventDefault();
-            this._addDraftAtTarget();
-          }
-          break;
-        case 219:  // '['
-          e.preventDefault();
-          this._openSelectedFile(this._files.length - 1);
-          break;
-        case 221:  // ']'
-          e.preventDefault();
-          this._openSelectedFile(0);
-          break;
-        case 13:  // <enter>
-        case 79:  // 'o'
-          e.preventDefault();
-          if (this._showInlineDiffs) {
-            this._openCursorFile();
-          } else {
-            this._openSelectedFile();
-          }
-          break;
-        case 78:  // 'n'
-          if (this._showInlineDiffs) {
-            e.preventDefault();
-            if (e.shiftKey) {
-              this.$.cursor.moveToNextCommentThread();
-            } else {
-              this.$.cursor.moveToNextChunk();
-            }
-          }
-          break;
-        case 80:  // 'p'
-          if (this._showInlineDiffs) {
-            e.preventDefault();
-            if (e.shiftKey) {
-              this.$.cursor.moveToPreviousCommentThread();
-            } else {
-              this.$.cursor.moveToPreviousChunk();
-            }
-          }
-          break;
-        case 65:  // 'a'
-          if (e.shiftKey) { // Hide left diff.
-            e.preventDefault();
-            this._forEachDiff(function(diff) {
-              diff.toggleLeftDiff();
-            });
-          }
-          break;
+      if (!this._showInlineDiffs) { return; }
+
+      e.preventDefault();
+      this.$.diffCursor.moveLeft();
+    },
+
+    _handleShiftRightKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+      if (!this._showInlineDiffs) { return; }
+
+      e.preventDefault();
+      this.$.diffCursor.moveRight();
+    },
+
+    _handleIKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+      if (this.$.fileCursor.index === -1) { return; }
+
+      e.preventDefault();
+      var expanded = this._files[this.$.fileCursor.index].__expanded;
+      // Until Polymer 2.0, manual management of reflection between _files
+      // and _shownFiles is necessary.
+      this.set(['_shownFiles', this.$.fileCursor.index, '__expanded'],
+          !expanded);
+      this.set(['_files', this.$.fileCursor.index, '__expanded'], !expanded);
+    },
+
+    _handleCapitalIKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      this._toggleInlineDiffs();
+    },
+
+    _handleDownKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+      e.preventDefault();
+      if (this._showInlineDiffs) {
+        this.$.diffCursor.moveDown();
+      } else {
+        this.$.fileCursor.next();
       }
     },
 
+    _handleUpKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      if (this._showInlineDiffs) {
+        this.$.diffCursor.moveUp();
+      } else {
+        this.$.fileCursor.previous();
+      }
+    },
+
+    _handleCKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      var isRangeSelected = this.diffs.some(function(diff) {
+        return diff.isRangeSelected();
+      }, this);
+      if (this._showInlineDiffs && !isRangeSelected) {
+        e.preventDefault();
+        this._addDraftAtTarget();
+      }
+    },
+
+    _handleLeftBracketKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      this._openSelectedFile(this._files.length - 1);
+    },
+
+    _handleRightBracketKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      this._openSelectedFile(0);
+    },
+
+    _handleEnterKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      if (this._showInlineDiffs) {
+        this._openCursorFile();
+      } else {
+        this._openSelectedFile();
+      }
+    },
+
+    _handleNKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+      if (!this._showInlineDiffs) { return; }
+
+      e.preventDefault();
+      if (e.shiftKey) {
+        this.$.diffCursor.moveToNextCommentThread();
+      } else {
+        this.$.diffCursor.moveToNextChunk();
+      }
+    },
+
+    _handlePKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+      if (!this._showInlineDiffs) { return; }
+
+      e.preventDefault();
+      if (e.shiftKey) {
+        this.$.diffCursor.moveToPreviousCommentThread();
+      } else {
+        this.$.diffCursor.moveToPreviousChunk();
+      }
+    },
+
+    _handleCapitalAKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      this._forEachDiff(function(diff) {
+        diff.toggleLeftDiff();
+      });
+    },
+
     _toggleInlineDiffs: function() {
       if (this._showInlineDiffs) {
         this._collapseAllDiffs();
@@ -413,45 +453,34 @@
     },
 
     _openCursorFile: function() {
-      var diff = this.$.cursor.getTargetDiffElement();
+      var diff = this.$.diffCursor.getTargetDiffElement();
       page.show(this._computeDiffURL(diff.changeNum, diff.patchRange,
           diff.path));
     },
 
     _openSelectedFile: function(opt_index) {
       if (opt_index != null) {
-        this.selectedIndex = opt_index;
+        this.$.fileCursor.setCursorAtIndex(opt_index);
       }
       page.show(this._computeDiffURL(this.changeNum, this.patchRange,
-          this._files[this.selectedIndex].__path));
+          this._files[this.$.fileCursor.index].__path));
     },
 
     _addDraftAtTarget: function() {
-      var diff = this.$.cursor.getTargetDiffElement();
-      var target = this.$.cursor.getTargetLineElement();
+      var diff = this.$.diffCursor.getTargetDiffElement();
+      var target = this.$.diffCursor.getTargetLineElement();
       if (diff && target) {
         diff.addDraftAtLine(target);
       }
     },
 
-    _scrollToSelectedFile: function() {
-      var el = this.$$('.row[selected]');
-      var top = 0;
-      for (var node = el; node; node = node.offsetParent) {
-        top += node.offsetTop;
-      }
-
-      // Don't scroll if it's already in view.
-      if (top > window.pageYOffset &&
-          top < window.pageYOffset + window.innerHeight - el.clientHeight) {
-        return;
-      }
-
-      window.scrollTo(0, top - document.body.clientHeight / 2);
+    _shouldHideChangeTotals: function(_patchChange) {
+      return _patchChange.inserted === 0 && _patchChange.deleted === 0;
     },
 
-    _shouldHideChangeTotals: function(_patchChange) {
-      return (_patchChange.inserted === 0 && _patchChange.deleted === 0);
+    _shouldHideBinaryChangeTotals: function(_patchChange) {
+      return _patchChange.size_delta_inserted === 0 &&
+          _patchChange.size_delta_deleted === 0;
     },
 
     _computeFileSelected: function(index, selectedIndex) {
@@ -477,6 +506,31 @@
       return path === COMMIT_MESSAGE_PATH ? 'Commit message' : path;
     },
 
+    _formatBytes: function(bytes) {
+      if (bytes == 0) return '+/-0 B';
+      var bits = 1024;
+      var decimals = 1;
+      var sizes = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
+      var exponent = Math.floor(Math.log(Math.abs(bytes)) / Math.log(bits));
+      var prepend = bytes > 0 ? '+' : '';
+      return prepend + parseFloat((bytes / Math.pow(bits, exponent))
+          .toFixed(decimals)) + ' ' + sizes[exponent];
+    },
+
+    _formatPercentage: function(size, delta) {
+      var oldSize = size - delta;
+
+      if (oldSize === 0) { return ''; }
+
+      var percentage = Math.round(Math.abs(delta * 100 / oldSize));
+      return '(' + (delta > 0 ? '+' : '-') + percentage + '%)';
+    },
+
+    _computeBinaryClass: function(delta) {
+      if (delta === 0) { return; }
+      return delta >= 0 ? 'added' : 'removed';
+    },
+
     _computeClass: function(baseClass, path) {
       var classes = [baseClass];
       if (path === COMMIT_MESSAGE_PATH) {
@@ -498,8 +552,12 @@
         var diffElements = Polymer.dom(this.root).querySelectorAll('gr-diff');
 
         // Overwrite the cursor's list of diffs:
-        this.$.cursor.splice.apply(this.$.cursor,
-            ['diffs', 0, this.$.cursor.diffs.length].concat(diffElements));
+        this.$.diffCursor.splice.apply(this.$.diffCursor,
+            ['diffs', 0, this.$.diffCursor.diffs.length].concat(diffElements));
+
+        var files = Polymer.dom(this.root).querySelectorAll('.file-row');
+        this.$.fileCursor.stops = files;
+        if (this.$.fileCursor.index === -1) { this.$.fileCursor.moveToStart(); }
       }.bind(this), 1);
     },
 
@@ -554,12 +612,13 @@
       return DiffViewMode.SIDE_BY_SIDE;
     },
 
-    _handleDropdownChange: function(e) {
-      e.target.blur();
-    },
-
     _fileListActionsVisible: function(numFilesShown, maxFilesForBulkActions) {
       return numFilesShown <= maxFilesForBulkActions;
     },
+
+    _computePatchSetDescription: function(revisions, patchNum) {
+      var rev = this.getRevisionNumber(revisions, patchNum);
+      return (rev && rev.description) ? rev.description : '';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
index b530bae..bc7c0c7 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
@@ -103,10 +103,30 @@
     test('calculate totals for patch number', function() {
       element._files = [
         {__path: '/COMMIT_MSG', lines_inserted: 9},
-        {__path: 'file_added_in_rev2.txt', lines_inserted: 1, lines_deleted: 1},
-        {__path: 'myfile.txt', lines_inserted: 1, lines_deleted: 1},
+        {
+          __path: 'file_added_in_rev2.txt',
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+        {
+          __path: 'myfile.txt',
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
       ];
-      assert.deepEqual(element._patchChange, {inserted: 2, deleted: 2});
+      assert.deepEqual(element._patchChange, {
+        inserted: 2,
+        deleted: 2,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
 
       // Test with a commit message that isn't the first file.
       element._files = [
@@ -114,21 +134,137 @@
         {__path: '/COMMIT_MSG', lines_inserted: 9},
         {__path: 'myfile.txt', lines_inserted: 1, lines_deleted: 1},
       ];
-      assert.deepEqual(element._patchChange, {inserted: 2, deleted: 2});
+      assert.deepEqual(element._patchChange, {
+        inserted: 2,
+        deleted: 2,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
 
       // Test with no commit message.
       element._files = [
         {__path: 'file_added_in_rev2.txt', lines_inserted: 1, lines_deleted: 1},
         {__path: 'myfile.txt', lines_inserted: 1, lines_deleted: 1},
       ];
-      assert.deepEqual(element._patchChange, {inserted: 2, deleted: 2});
+      assert.deepEqual(element._patchChange, {
+        inserted: 2,
+        deleted: 2,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
 
       // Test with files missing either lines_inserted or lines_deleted.
       element._files = [
         {__path: 'file_added_in_rev2.txt', lines_inserted: 1},
         {__path: 'myfile.txt', lines_deleted: 1},
       ];
-      assert.deepEqual(element._patchChange, {inserted: 1, deleted: 1});
+      assert.deepEqual(element._patchChange, {
+        inserted: 1,
+        deleted: 1,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
+    });
+
+    test('binary only files', function() {
+      element._files = [
+        {__path: '/COMMIT_MSG', lines_inserted: 9},
+        {__path: 'file_binary', binary: true, size_delta: 10, size: 100},
+        {__path: 'file_binary', binary: true, size_delta: -5, size: 120},
+      ];
+      assert.deepEqual(element._patchChange, {
+        inserted: 0,
+        deleted: 0,
+        size_delta_inserted: 10,
+        size_delta_deleted: -5,
+        total_size: 220,
+      });
+      assert.isFalse(element._hideBinaryChangeTotals);
+      assert.isTrue(element._hideChangeTotals);
+    });
+
+    test('binary and regular files', function() {
+      element._files = [
+        {__path: '/COMMIT_MSG', lines_inserted: 9},
+        {__path: 'file_binary', binary: true, size_delta: 10, size: 100},
+        {__path: 'file_binary', binary: true, size_delta: -5, size: 120},
+        {__path: 'myfile.txt', lines_deleted: 5, size_delta: -10, size: 100},
+        {__path: 'myfile2.txt', lines_inserted: 10},
+      ];
+      assert.deepEqual(element._patchChange, {
+        inserted: 10,
+        deleted: 5,
+        size_delta_inserted: 10,
+        size_delta_deleted: -5,
+        total_size: 220,
+      });
+      assert.isFalse(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
+    });
+
+    test('_formatBytes function', function() {
+      var table = {
+        64: '+64 B',
+        1023: '+1023 B',
+        1024: '+1 KiB',
+        4096: '+4 KiB',
+        1073741824: '+1 GiB',
+        '-64': '-64 B',
+        '-1023': '-1023 B',
+        '-1024': '-1 KiB',
+        '-4096': '-4 KiB',
+        '-1073741824': '-1 GiB',
+        0: '+/-0 B',
+      };
+
+      for (var bytes in table) {
+        if (table.hasOwnProperty(bytes)) {
+          assert.equal(element._formatBytes(bytes), table[bytes]);
+        }
+      }
+    });
+
+    test('_formatPercentage function', function() {
+      var table = [
+        { size: 100,
+          delta: 100,
+          display: '',
+        },
+        { size: 195060,
+          delta: 64,
+          display: '(+0%)',
+        },
+        { size: 195060,
+          delta: -64,
+          display: '(-0%)',
+        },
+        { size: 394892,
+          delta: -7128,
+          display: '(-2%)',
+        },
+        { size: 90,
+          delta: -10,
+          display: '(-10%)',
+        },
+        { size: 110,
+          delta: 10,
+          display: '(+10%)',
+        },
+      ];
+
+      table.forEach(function(item) {
+        assert.equal(element._formatPercentage(
+            item.size, item.delta), item.display);
+      });
     });
 
     suite('keyboard shortcuts', function() {
@@ -143,7 +279,7 @@
           basePatchNum: 'PARENT',
           patchNum: '2',
         };
-        element.selectedIndex = 0;
+        element.$.fileCursor.setCursorAtIndex(0);
       });
 
       test('toggle left diff via shortcut', function() {
@@ -155,62 +291,64 @@
             return [{toggleLeftDiff: toggleLeftDiffStub}];
           },
         });
-        MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift');  // 'A'
+        MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
         assert.isTrue(toggleLeftDiffStub.calledOnce);
         diffsStub.restore();
       });
 
       test('keyboard shortcuts', function() {
         flushAsynchronousOperations();
-        var elementItems = Polymer.dom(element.root).querySelectorAll(
-            '.row:not(.header)');
-        assert.equal(elementItems.length, 4);
-        assert.isTrue(elementItems[0].hasAttribute('selected'));
-        assert.isFalse(elementItems[1].hasAttribute('selected'));
-        assert.isFalse(elementItems[2].hasAttribute('selected'));
-        MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'J'
-        assert.equal(element.selectedIndex, 1);
-        MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'J'
+
+        var items = Polymer.dom(element.root).querySelectorAll('.file-row');
+        element.$.fileCursor.stops = items;
+        element.$.fileCursor.setCursorAtIndex(0);
+        assert.equal(items.length, 3);
+        assert.isTrue(items[0].classList.contains('selected'));
+        assert.isFalse(items[1].classList.contains('selected'));
+        assert.isFalse(items[2].classList.contains('selected'));
+        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+        assert.equal(element.$.fileCursor.index, 1);
+        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
 
         var showStub = sandbox.stub(page, 'show');
-        assert.equal(element.selectedIndex, 2);
-        MockInteractions.pressAndReleaseKeyOn(element, 13);  // 'ENTER'
+        assert.equal(element.$.fileCursor.index, 2);
+        MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
         assert(showStub.lastCall.calledWith('/c/42/2/myfile.txt'),
             'Should navigate to /c/42/2/myfile.txt');
 
-        MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'K'
-        assert.equal(element.selectedIndex, 1);
-        MockInteractions.pressAndReleaseKeyOn(element, 79);  // 'O'
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        assert.equal(element.$.fileCursor.index, 1);
+        MockInteractions.pressAndReleaseKeyOn(element, 79, null, 'o');
         assert(showStub.lastCall.calledWith('/c/42/2/file_added_in_rev2.txt'),
             'Should navigate to /c/42/2/file_added_in_rev2.txt');
 
-        MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'K'
-        MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'K'
-        MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'K'
-        assert.equal(element.selectedIndex, 0);
-
-        showStub.restore();
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        assert.equal(element.$.fileCursor.index, 0);
       });
 
       test('i key shows/hides selected inline diff', function() {
-        element.selectedIndex = 0;
-        MockInteractions.pressAndReleaseKeyOn(element, 73);  // 'I'
+        flushAsynchronousOperations();
+        element.$.fileCursor.stops = element.diffs;
+        element.$.fileCursor.setCursorAtIndex(0);
+        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
         flushAsynchronousOperations();
         assert.isFalse(element.diffs[0].hasAttribute('hidden'));
-        MockInteractions.pressAndReleaseKeyOn(element, 73);  // 'I'
+        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
         flushAsynchronousOperations();
         assert.isTrue(element.diffs[0].hasAttribute('hidden'));
-        element.selectedIndex = 1;
-        MockInteractions.pressAndReleaseKeyOn(element, 73);  // 'I'
+        element.$.fileCursor.setCursorAtIndex(1);
+        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
         flushAsynchronousOperations();
         assert.isFalse(element.diffs[1].hasAttribute('hidden'));
 
-        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift');  // 'I'
+        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
         flushAsynchronousOperations();
         for (var index in element.diffs) {
           assert.isFalse(element.diffs[index].hasAttribute('hidden'));
         }
-        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift');  // 'I'
+        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
         flushAsynchronousOperations();
         for (var index in element.diffs) {
           assert.isTrue(element.diffs[index].hasAttribute('hidden'));
@@ -280,7 +418,7 @@
         basePatchNum: 'PARENT',
         patchNum: '2',
       };
-      element.selectedIndex = 0;
+      element.$.fileCursor.setCursorAtIndex(0);
 
       flushAsynchronousOperations();
       var fileRows =
@@ -363,7 +501,7 @@
         basePatchNum: 'PARENT',
         patchNum: '2',
       };
-      element.selectedIndex = 0;
+      element.$.fileCursor.setCursorAtIndex(0);
       flushAsynchronousOperations();
       var fileRows =
           Polymer.dom(element.root).querySelectorAll('.row:not(.header)');
@@ -401,7 +539,7 @@
         basePatchNum: 'PARENT',
         patchNum: '2',
       };
-      element.selectedIndex = 0;
+      element.$.fileCursor.setCursorAtIndex(0);
       flushAsynchronousOperations();
       var diffDisplay = element.diffs[0];
       element._userPrefs = {diff_view: 'SIDE_BY_SIDE'};
@@ -444,7 +582,7 @@
         patchNum: '2',
       };
       var computeSpy = sandbox.spy(element, '_fileListActionsVisible');
-      element.selectedIndex = 0;
+      element.$.fileCursor.setCursorAtIndex(0);
       element._numFilesShown = 1;
       flush(function() {
         assert.isTrue(computeSpy.lastCall.returnValue);
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.html b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
index 3649655..3435547 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
@@ -18,7 +18,7 @@
 <link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
+<link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <link rel="import" href="../gr-comment-list/gr-comment-list.html">
@@ -39,10 +39,10 @@
         left: var(--default-horizontal-margin);
       }
       .collapsed .contentContainer {
+        align-items: baseline;
         color: #777;
+        display: flex;
         white-space: nowrap;
-        overflow-x: hidden;
-        text-overflow: ellipsis;
       }
       .showAvatar.expanded .contentContainer {
         margin-left: calc(var(--default-horizontal-margin) + 2.5em);
@@ -51,10 +51,6 @@
       .showAvatar.collapsed .contentContainer {
         margin-left: calc(var(--default-horizontal-margin) + 1.75em);
       }
-      .showAvatar.collapsed .contentContainer,
-      .hideAvatar.collapsed .contentContainer {
-        margin-right: calc(var(--default-horizontal-margin) + 2.25em);
-      }
       .hideAvatar.collapsed .contentContainer,
       .hideAvatar.expanded .contentContainer {
         margin-left: 0;
@@ -77,12 +73,13 @@
       .name {
         font-weight: bold;
       }
-      .content {
-        font-family: var(--monospace-font-family);
-      }
       .message {
         max-width: 80ch;
       }
+      .collapsed .message {
+        overflow: hidden;
+        text-overflow: ellipsis;
+      }
       .collapsed .name,
       .collapsed .content,
       .collapsed .message,
@@ -90,11 +87,27 @@
         display: inline;
       }
       .collapsed gr-comment-list,
-      .collapsed .replyContainer {
+      .collapsed .replyContainer,
+      .collapsed .hideOnCollapsed,
+      .hideOnOpen {
         display: none;
       }
+      .collapsed .hideOnOpen {
+        display: block;
+      }
+      .collapsed .content {
+        flex: 1;
+        margin-right: .25em;
+        min-width: 0;
+        overflow: hidden;
+        text-overflow: ellipsis;
+      }
+      .collapsed .date {
+        position: static;
+      }
       .collapsed .name {
         color: var(--default-text-color);
+        margin-right: .4em;
       }
       .expanded .name {
         cursor: pointer;
@@ -115,12 +128,11 @@
         <div class="name" on-tap="_handleNameTap">[[author.name]]</div>
         <template is="dom-if" if="[[message.message]]">
           <div class="content">
-            <gr-linked-text
-                class="message"
-                pre="[[expanded]]"
+            <div class="message hideOnOpen">[[message.message]]</div>
+            <gr-formatted-text
+                class="message hideOnCollapsed"
                 content="[[message.message]]"
-                disabled="[[!expanded]]"
-                config="[[projectConfig.commentlinks]]"></gr-linked-text>
+                config="[[projectConfig.commentlinks]]"></gr-formatted-text>
             <gr-comment-list
                 comments="[[comments]]"
                 change-num="[[changeNum]]"
@@ -132,10 +144,12 @@
           </a>
         </template>
         <template is="dom-if" if="[[message.reviewer]]">
-          set reviewer status for
-          <gr-account-chip account="[[message.reviewer]]">
-          </gr-account-chip>
-          to [[message.state]].
+          <div class="content">
+            set reviewer status for
+            <gr-account-chip account="[[message.reviewer]]">
+            </gr-account-chip>
+            to [[message.state]].
+          </div>
           <gr-date-formatter class="date" date-str="[[message.updated]]">
           </gr-date-formatter>
         </template>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.js b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
index 5c903a85..244f7d3 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -115,7 +115,8 @@
     },
 
     _computeIsAutomated: function(message) {
-      return !!message.tag && message.tag.indexOf('autogenerated') === 0;
+      return !!(message.reviewer ||
+          (message.tag && message.tag.indexOf('autogenerated') === 0));
     },
 
     _computeIsHidden: function(hideAutomated, isAutomated) {
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
index 4615392..b8c12af 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
@@ -101,6 +101,21 @@
       assert.isTrue(element.hidden);
     });
 
+    test('reviewer message treated as autogenerated', function() {
+      element.message = {
+        tag: 'autogenerated:gerrit:test',
+        updated: '2016-01-12 20:24:49.448000000',
+        reviewer: {},
+      };
+
+      assert.isTrue(element.isAutomated);
+      assert.isFalse(element.hidden);
+
+      element.hideAutomated = true;
+
+      assert.isTrue(element.hidden);
+    });
+
     test('tag that is not autogenerated prefix does not hide', function() {
       element.message = {
         tag: 'something',
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
index c80ba4a..7c1759d 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
@@ -48,11 +48,15 @@
             on-tap="_handleExpandCollapseTap">
           [[_computeExpandCollapseMessage(_expanded)]]
         </gr-button>
-        <gr-button id="automatedMessageToggle" link
-            on-tap="_handleAutomatedMessageToggleTap"
+        <span
+            id="automatedMessageToggleContainer"
             hidden$="[[!_hasAutomatedMessages(messages)]]">
-          [[_computeAutomatedToggleText(_hideAutomated)]]
-        </gr-button>
+          /
+          <gr-button id="automatedMessageToggle" link
+              on-tap="_handleAutomatedMessageToggleTap">
+            [[_computeAutomatedToggleText(_hideAutomated)]]
+          </gr-button>
+        </span>
       </div>
     </div>
     <template
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
index aa0703f..dc98527 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
@@ -126,8 +126,8 @@
 
     _hasAutomatedMessages: function(messages) {
       for (var i = 0; messages && i < messages.length; i++) {
-        if (messages[i].tag &&
-            messages[i].tag.indexOf('autogenerated') === 0) {
+        if (messages[i].reviewer || (messages[i].tag &&
+            messages[i].tag.indexOf('autogenerated') === 0)) {
           return true;
         }
       }
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
index 704c638..51aceba 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
@@ -88,7 +88,7 @@
 
     test('hide messages does not appear when no automated messages',
         function() {
-      assert.isOk(element.$$('#automatedMessageToggle[hidden]'));
+      assert.isOk(element.$$('#automatedMessageToggleContainer[hidden]'));
     });
 
     test('scroll to message', function() {
@@ -231,13 +231,18 @@
       };
     };
 
+    var randomMessageReviewer = {
+      reviewer: {},
+    };
+
     setup(function() {
       stub('gr-rest-api-interface', {
         getConfig: function() { return Promise.resolve({}); },
         getLoggedIn: function() { return Promise.resolve(false); },
       });
       element = fixture('basic');
-      messages = _.times(3, randomMessage);
+      messages = _.times(2, randomMessage);
+      messages.push(randomMessageReviewer);
       element.messages = messages;
       flushAsynchronousOperations();
     });
@@ -261,11 +266,13 @@
 
       element._hideAutomated = false;
       MockInteractions.tap(element.$$('#automatedMessageToggle'));
+      allMessageEls =
+          Polymer.dom(element.root).querySelectorAll('gr-message');
       allHiddenMessageEls =
           Polymer.dom(element.root).querySelectorAll('gr-message[hidden]');
 
-      //Autogenerated messages are now hidden.
-      assert.isTrue(!!allHiddenMessageEls.length);
+      // Autogenerated messages are now hidden.
+      assert.equal(allHiddenMessageEls.length, allMessageEls.length);
     });
 
     test('autogenerated messages are not hidden after clicking show button',
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
index bf8a95f1..9b7a11f0 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
@@ -138,6 +138,14 @@
       .action:visited {
         color: #00e;
       }
+      @media screen and (max-width: 50em) {
+        :host {
+          max-height: none;
+        }
+        .container {
+          max-height: none;
+        }
+      }
     </style>
     <div class="container">
       <section class="peopleContainer">
@@ -199,6 +207,7 @@
         <iron-autogrow-textarea
             id="textarea"
             class="message"
+            autocomplete="on"
             placeholder="Say something..."
             disabled="{{disabled}}"
             rows="4"
@@ -208,12 +217,6 @@
         </iron-autogrow-textarea>
       </section>
       <section class="labelsContainer">
-        <template is="dom-if" if="[[_isClosed(change)]]" id="labelDisabled">
-          <div class="labelDisabledMessage">
-            Setting labels are disabled for this change because it has been
-            closed.
-          </div>
-        </template>
         <template is="dom-repeat"
             items="[[_labels]]" as="label">
           <div class="labelContainer">
@@ -235,6 +238,7 @@
         <gr-comment-list
             comments="[[diffDrafts]]"
             change-num="[[change._number]]"
+            project-config="[[projectConfig]]"
             patch-num="[[patchNum]]"></gr-comment-list>
       </section>
       <section class="actionsContainer">
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
index c812537..3f27234 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
@@ -23,8 +23,6 @@
     REVIEWERS: 'reviewers',
   };
 
-  var CLOSED_CHANGE_STATUSES = ['ABANDONED', 'MERGED'];
-
   Polymer({
     is: 'gr-reply-dialog',
 
@@ -73,6 +71,7 @@
       },
       permittedLabels: Object,
       serverConfig: Object,
+      projectConfig: Object,
 
       _account: Object,
       _ccs: Array,
@@ -276,10 +275,6 @@
       }.bind(this));
     },
 
-    _isClosed: function(change) {
-      return CLOSED_CHANGE_STATUSES.indexOf(change.status) !== -1;
-    },
-
     _computeHideDraftList: function(drafts) {
       return Object.keys(drafts || {}).length == 0;
     },
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
index 0e5eb25..72fbaaf 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
@@ -128,46 +128,49 @@
 
       // Async tick is needed because iron-selector content is distributed and
       // distributed content requires an observer to be set up.
+      // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
       flush(function() {
-        for (var label in element.permittedLabels) {
-          assert.ok(element.$$('iron-selector[data-label="' + label + '"]'),
-              label);
-        }
-        element.draft = 'I wholeheartedly disapprove';
-        MockInteractions.tap(element.$$(
-            'iron-selector[data-label="Code-Review"] > ' +
-            'gr-button[data-value="-1"]'));
-        MockInteractions.tap(element.$$(
-            'iron-selector[data-label="Verified"] > ' +
-            'gr-button[data-value="-1"]'));
-
-        var saveReviewStub = sinon.stub(element, '_saveReview',
-            function(review) {
-          assert.deepEqual(review, {
-            drafts: 'PUBLISH_ALL_REVISIONS',
-            labels: {
-              'Code-Review': -1,
-              'Verified': -1,
-            },
-            message: 'I wholeheartedly disapprove',
-            reviewers: [],
-          });
-          return Promise.resolve({ok: true});
-        });
-
-        element.addEventListener('send', function() {
-          assert.isFalse(element.disabled,
-              'Element should be enabled when done sending reply.');
-          assert.equal(element.draft.length, 0);
-          saveReviewStub.restore();
-          done();
-        });
-
-        // This is needed on non-Blink engines most likely due to the ways in
-        // which the dom-repeat elements are stamped.
         flush(function() {
-          MockInteractions.tap(element.$$('.send'));
-          assert.isTrue(element.disabled);
+          for (var label in element.permittedLabels) {
+            assert.ok(element.$$('iron-selector[data-label="' + label + '"]'),
+                label);
+          }
+          element.draft = 'I wholeheartedly disapprove';
+          MockInteractions.tap(element.$$(
+              'iron-selector[data-label="Code-Review"] > ' +
+              'gr-button[data-value="-1"]'));
+          MockInteractions.tap(element.$$(
+              'iron-selector[data-label="Verified"] > ' +
+              'gr-button[data-value="-1"]'));
+
+          var saveReviewStub = sinon.stub(element, '_saveReview',
+              function(review) {
+            assert.deepEqual(review, {
+              drafts: 'PUBLISH_ALL_REVISIONS',
+              labels: {
+                'Code-Review': -1,
+                'Verified': -1,
+              },
+              message: 'I wholeheartedly disapprove',
+              reviewers: [],
+            });
+            return Promise.resolve({ok: true});
+          });
+
+          element.addEventListener('send', function() {
+            assert.isFalse(element.disabled,
+                'Element should be enabled when done sending reply.');
+            assert.equal(element.draft.length, 0);
+            saveReviewStub.restore();
+            done();
+          });
+
+          // This is needed on non-Blink engines most likely due to the ways in
+          // which the dom-repeat elements are stamped.
+          flush(function() {
+            MockInteractions.tap(element.$$('.send'));
+            assert.isTrue(element.disabled);
+          });
         });
       });
     });
@@ -259,18 +262,6 @@
       }).then(done);
     });
 
-    test('message disabled dialogue appears for closed change', function() {
-      element.change = {status: 'ABANDONED'};
-      flushAsynchronousOperations();
-      assert.isOk(element.$$('.labelDisabledMessage'));
-    });
-
-    test('message disabled dialogue does not appear for open change',
-        function() {
-      element.change = {status: 'NEW'};
-      assert.isNotOk(element.$$('.labelDisabledMessage'));
-    });
-
     test('_getStorageLocation', function() {
       var actual = element._getStorageLocation();
       assert.equal(actual.changeNum, changeNum);
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
index 1d31c12..ceaf02d 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
@@ -17,21 +17,12 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-account-dropdown">
   <template>
     <style>
-      :host {
-        display: inline-block;
-      }
-      .dropdown-trigger {
-        text-decoration: none;
-      }
-      .dropdown-content {
-        background-color: #fff;
-        box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
-      }
       button {
         background: none;
         border: none;
@@ -43,51 +34,15 @@
         width: 2em;
         vertical-align: middle;
       }
-      ul {
-        list-style: none;
-      }
-      ul .accountName {
-        font-weight: bold;
-      }
-      li .accountInfo,
-      li a {
-        display: block;
-        padding: .85em 1em;
-      }
-      li a:link,
-      li a:visited {
-        color: #00e;
-        text-decoration: none;
-      }
-      li a:hover {
-        background-color: #6B82D6;
-        color: #fff;
-      }
     </style>
-    <gr-button link class="dropdown-trigger" id="trigger"
-        on-tap="_showDropdownTapHandler">
-      <span hidden$="[[_hasAvatars]]" hidden>[[account.name]]</span>
-      <gr-avatar account="[[account]]" hidden$="[[!_hasAvatars]]" hidden
-          image-size="56"></gr-avatar>
-    </gr-button>
-    <iron-dropdown id="dropdown"
-        vertical-align="top"
-        vertical-offset="25"
+    <gr-dropdown items=[[links]] top-content=[[topContent]]
         horizontal-align="right">
-      <div class="dropdown-content">
-        <ul>
-          <li>
-            <div class="accountInfo">
-              <div class="accountName">[[account.name]]</div>
-              <div>[[account.email]]</div>
-            </div>
-          </li>
-          <li><a href$="[[_computeRelativeURL('/settings')]]">Settings</a></li>
-          <li><a href$="[[_computeRelativeURL('/switch-account')]]">Switch account</a></li>
-          <li><a href$="[[_computeRelativeURL('/logout')]]">Sign out</a></li>
-        </ul>
-      </div>
-    </iron-dropdown>
+      <gr-button link class="dropdown-trigger" id="trigger">
+        <span hidden$="[[_hasAvatars]]" hidden>[[account.name]]</span>
+        <gr-avatar account="[[account]]" hidden$="[[!_hasAvatars]]" hidden
+            image-size="56"></gr-avatar>
+      </gr-button>
+    </gr-dropdown>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-account-dropdown.js"></script>
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
index ad944dc..4011135 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
@@ -20,26 +20,31 @@
     properties: {
       account: Object,
       _hasAvatars: Boolean,
+      links: {
+        type: Array,
+        value: [
+          {name: 'Settings', url: '/settings'},
+          {name: 'Switch account', url: '/switch-account'},
+          {name: 'Sign out', url: '/logout'},
+        ],
+      },
+      topContent: {
+        type: Array,
+        computed: '_getTopContent(account)',
+      },
     },
 
     attached: function() {
       this.$.restAPI.getConfig().then(function(cfg) {
         this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
       }.bind(this));
-
-      this.listen(this.$.dropdown, 'tap', '_handleDropdownTap');
     },
 
-    _handleDropdownTap: function(e) {
-      this.$.dropdown.close();
-    },
-
-    _showDropdownTapHandler: function(e) {
-      this.$.dropdown.open();
-    },
-
-    _computeRelativeURL: function(path) {
-      return '//' + window.location.host + path;
+    _getTopContent: function(account) {
+      return [
+        {text: account.name, bold: true},
+        {text: account.email},
+      ];
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
index 8c39da8..ec9141f 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
@@ -41,11 +41,10 @@
       element = fixture('basic');
     });
 
-    test('tap on trigger opens menu', function() {
-      assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.tap(element.$.trigger);
-      assert.isTrue(element.$.dropdown.opened);
+    test('account information', function() {
+      element.account = {name: 'John Doe', email: 'john@doe.com'};
+      assert.deepEqual(element.topContent,
+          [{text: 'John Doe', bold: true}, {text: 'john@doe.com'}]);
     });
-
   });
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
index 3e30810..76a1c1f 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
@@ -17,8 +17,8 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
 <link rel="import" href="../gr-account-dropdown/gr-account-dropdown.html">
+<link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
 <link rel="import" href="../gr-search-bar/gr-search-bar.html">
 
 <dom-module id="gr-main-header">
@@ -42,12 +42,6 @@
       ul {
         list-style: none;
       }
-      .links {
-        margin-left: 1em;
-      }
-      .links .menuContainer {
-        display: none;
-      }
       .links > li {
         cursor: default;
         display: inline-block;
@@ -55,31 +49,8 @@
         padding: .5em 0;
         position: relative;
       }
-      .links li:hover .menuContainer,
-      .links li:active .menuContainer {
-        background-color: #fff;
-        border-radius: 3px;
-        box-shadow: 0 1px 1px rgba(0, 0, 0, .3);
-        display: block;
-        left: -.5em;
-        padding: .5em 0;
-        position: absolute;
-        top: 2.4em;
-        z-index: 1000;
-      }
-      .links li ul li a:link,
-      .links li ul li a:visited {
-        color: #00e;
-        display: block;
-        padding: .3em 1em;
-        text-decoration: none;
-        white-space: nowrap;
-      }
-      .links li ul li:hover a,
-      .links li ul li:active a {
-        background-color: var(--selection-background-color);
-      }
       .linksTitle {
+        color: black;
         display: inline-block;
         padding-right: 1em;
         position: relative;
@@ -121,6 +92,13 @@
         overflow: hidden;
         text-overflow: ellipsis;
       }
+      .dropdown-trigger {
+        text-decoration: none;
+      }
+      .dropdown-content {
+        background-color: #fff;
+        box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
+      }
       @media screen and (max-width: 50em) {
         .bigTitle {
           font-size: 14px;
@@ -139,16 +117,13 @@
       <ul class="links">
         <template is="dom-repeat" items="[[_links]]" as="linkGroup">
           <li>
-            <span class="linksTitle">
+          <gr-dropdown
+              items = [[linkGroup.links]]
+              horizontal-align="left">
+            <span class="linksTitle" id="[[linkGroup.title]]">
               [[linkGroup.title]] <i class="downArrow"></i>
             </span>
-            <div class="menuContainer">
-              <ul>
-                <template is="dom-repeat" items="[[linkGroup.links]]" as="link">
-                  <li><a href$="[[link.url]]">[[link.name]]</a></li>
-                </template>
-              </ul>
-            </div>
+          </gr-dropdown>
           </li>
         </template>
       </ul>
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
index 2f017de..ebeb9af 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
@@ -36,7 +36,7 @@
     is: 'gr-main-header',
 
     hostAttributes: {
-      role: 'banner'
+      role: 'banner',
     },
 
     properties: {
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
index 85e72c0..74344a7 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -145,7 +145,7 @@
         // TODO(kaspern): Utilize gr-url-encoding-behavior.html when the router
         // is replaced with a Polymer counterpart.
         // @see Issue 4255 regarding double-encoding.
-        var path = encodeURIComponent(encodeURIComponent(path));
+        var path = encodeURIComponent(encodeURIComponent(params.path));
         // @see Issue 4577 regarding more readable URLs.
         path = path.replace(/%252F/g, '/');
         path = path.replace(/%2520/g, '+');
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
index 2c8549d..61eb280 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
@@ -94,6 +94,10 @@
       'searchButton.tap': '_preventDefaultAndNavigateToInputVal',
     },
 
+    keyBindings: {
+      '/': '_handleForwardSlashKey',
+    },
+
     properties: {
       value: {
         type: String,
@@ -292,18 +296,12 @@
           });
     },
 
-    _handleKey: function(e) {
+    _handleForwardSlashKey: function(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-      switch (e.keyCode) {
-        case 191:  // '/' or '?' with shift key.
-          // TODO(andybons): Localization using e.key/keypress event.
-          if (e.shiftKey) { break; }
-          e.preventDefault();
-          var s = this.$.searchInput;
-          s.focus();
-          s.setSelectionRange(0, s.value.length);
-          break;
-      }
+
+      e.preventDefault();
+      this.$.searchInput.focus();
+      this.$.searchInput.selectAll();
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
index cd218d4..621511f 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
@@ -70,13 +70,15 @@
         done();
       });
       element.value = 'test';
-      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13);
+      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+          null, 'enter');
     });
 
     test('search query should be double-escaped', function() {
       var showStub = sinon.stub(page, 'show');
       element.$.searchInput.text = 'fate/stay';
-      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13);
+      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+          null, 'enter');
       assert.equal(showStub.lastCall.args[0], '/q/fate%252Fstay');
       showStub.restore();
     });
@@ -85,7 +87,8 @@
       var showStub = sinon.stub(page, 'show');
       var blurSpy = sinon.spy(element.$.searchInput.$.input, 'blur');
       element.$.searchInput.text = 'fate/stay';
-      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13);
+      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+          null, 'enter');
       assert.isTrue(blurSpy.called);
       showStub.restore();
       blurSpy.restore();
@@ -94,10 +97,19 @@
     test('empty search query does not trigger nav', function() {
       var showSpy = sinon.spy(page, 'show');
       element.value = '';
-      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13);
+      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+          null, 'enter');
       assert.isFalse(showSpy.called);
     });
 
+    test('keyboard shortcuts', function() {
+      var focusSpy = sinon.spy(element.$.searchInput, 'focus');
+      var selectAllSpy = sinon.spy(element.$.searchInput, 'selectAll');
+      MockInteractions.pressAndReleaseKeyOn(document.body, 191, null, '/');
+      assert.isTrue(focusSpy.called);
+      assert.isTrue(selectAllSpy.called);
+    });
+
     suite('_getSearchSuggestions',
         function() {
       setup(function() {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
index c944db4..6066c26 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
@@ -62,6 +62,8 @@
       // syntax highlighting for the entire file.
       var SYNTAX_MAX_LINE_LENGTH = 500;
 
+      var TRAILING_WHITESPACE_PATTERN = /\s+$/;
+
       Polymer({
         is: 'gr-diff-builder',
 
@@ -101,6 +103,7 @@
         attached: function() {
           // Setup annotation layers.
           this._layers = [
+            this._createTrailingWhitespaceLayer(),
             this.$.syntaxLayer,
             this._createIntralineLayer(),
             this._createTabIndicatorLayer(),
@@ -115,6 +118,7 @@
         render: function(comments, prefs) {
           this.$.syntaxLayer.enabled = prefs.syntax_highlighting;
           this._showTabs = !!prefs.show_tabs;
+          this._showTrailingWhitespace = !!prefs.show_whitespace_errors;
 
           // Stop the processor (if it's running).
           this.$.processor.cancel();
@@ -323,8 +327,6 @@
 
         _createIntralineLayer: function() {
           return {
-            addListener: function() {},
-
             // Take a DIV.contentText element and a line object with intraline
             // differences to highlight and apply them to the element as
             // annotations.
@@ -351,9 +353,8 @@
         },
 
         _createTabIndicatorLayer: function() {
-          var show = (function() { return this._showTabs; }).bind(this);
+          var show = function() { return this._showTabs; }.bind(this);
           return {
-            addListener: function() {},
             annotate: function(el, line) {
               // If visible tabs are disabled, do nothing.
               if (!show()) { return; }
@@ -375,6 +376,29 @@
           };
         },
 
+        _createTrailingWhitespaceLayer: function() {
+          var show = function() {
+            return this._showTrailingWhitespace;
+          }.bind(this);
+
+          return {
+            annotate: function(el, line) {
+              if (!show()) { return; }
+
+              var match = line.text.match(TRAILING_WHITESPACE_PATTERN);
+              if (match) {
+                // Normalize string positions in case there is unicode before or
+                // within the match.
+                var index = GrAnnotation.getStringLength(
+                    line.text.substr(0, match.index));
+                var length = GrAnnotation.getStringLength(match[0]);
+                GrAnnotation.annotateElement(el, index, length,
+                    'style-scope gr-diff trailing-whitespace');
+              }
+            },
+          };
+        },
+
         /**
          * In pages with large diffs, creating the first comment thread can be
          * slow because nested Polymer elements (particularly
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
index d987972..497cae4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
@@ -29,7 +29,9 @@
     this.layers = layers || [];
 
     this.layers.forEach(function(layer) {
-      layer.addListener(this._handleLayerUpdate.bind(this));
+      if (layer.addListener) {
+        layer.addListener(this._handleLayerUpdate.bind(this));
+      }
     }.bind(this));
   }
 
@@ -382,9 +384,6 @@
     var text = line.text;
     if (line.type !== GrDiffLine.Type.BLANK) {
       td.classList.add('content');
-      if (!text) {
-        text = '\xa0';
-      }
     }
     td.classList.add(line.type);
     var html = util.escapeHTML(text);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
index ad5173d..80f48ff 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
@@ -558,6 +558,100 @@
       });
     });
 
+    suite('trailing whitespace', function() {
+      var sandbox;
+      var element;
+      var layer;
+
+      setup(function() {
+        sandbox = sinon.sandbox.create();
+        element = fixture('basic');
+        element._showTrailingWhitespace = true;
+        layer = element._createTrailingWhitespaceLayer();
+      });
+
+      teardown(function() {
+        sandbox.restore();
+      });
+
+      test('does nothing with empty line', function() {
+        var line = {text: ''};
+        var el = document.createElement('div');
+        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        layer.annotate(el, line);
+        assert.isFalse(annotateElementStub.called);
+      });
+
+      test('does nothing with no trailing whitespace', function() {
+        var str = 'lorem ipsum blah blah';
+        var line = {text: str};
+        var el = document.createElement('div');
+        el.textContent = str;
+        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        layer.annotate(el, line);
+        assert.isFalse(annotateElementStub.called);
+      });
+
+      test('annotates trailing spaces', function() {
+        var str = 'lorem ipsum   ';
+        var line = {text: str};
+        var el = document.createElement('div');
+        el.textContent = str;
+        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        layer.annotate(el, line);
+        assert.isTrue(annotateElementStub.called);
+        assert.equal(annotateElementStub.lastCall.args[1], 11);
+        assert.equal(annotateElementStub.lastCall.args[2], 3);
+      });
+
+      test('annotates trailing tabs', function() {
+        var str = 'lorem ipsum\t\t\t';
+        var line = {text: str};
+        var el = document.createElement('div');
+        el.textContent = str;
+        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        layer.annotate(el, line);
+        assert.isTrue(annotateElementStub.called);
+        assert.equal(annotateElementStub.lastCall.args[1], 11);
+        assert.equal(annotateElementStub.lastCall.args[2], 3);
+      });
+
+      test('annotates mixed trailing whitespace', function() {
+        var str = 'lorem ipsum\t \t';
+        var line = {text: str};
+        var el = document.createElement('div');
+        el.textContent = str;
+        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        layer.annotate(el, line);
+        assert.isTrue(annotateElementStub.called);
+        assert.equal(annotateElementStub.lastCall.args[1], 11);
+        assert.equal(annotateElementStub.lastCall.args[2], 3);
+      });
+
+      test('unicode preceding trailing whitespace', function() {
+        var str = '💢\t';
+        var line = {text: str};
+        var el = document.createElement('div');
+        el.textContent = str;
+        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        layer.annotate(el, line);
+        assert.isTrue(annotateElementStub.called);
+        assert.equal(annotateElementStub.lastCall.args[1], 1);
+        assert.equal(annotateElementStub.lastCall.args[2], 1);
+      });
+
+      test('does not annotate when disabled', function() {
+        element._showTrailingWhitespace = false;
+        var str = 'lorem upsum\t \t ';
+        var line = {text: str};
+        var el = document.createElement('div');
+        el.textContent = str;
+        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        layer.annotate(el, line);
+        assert.isFalse(annotateElementStub.called);
+      });
+    });
+
     suite('rendering', function() {
       var content;
       var outputEl;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
index d0a49dd..864294b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
@@ -22,8 +22,10 @@
   <template>
     <style>
       :host {
+        background-color: #ffd;
         border: 1px solid #bbb;
         display: block;
+        padding: 0 .7em;
         white-space: normal;
       }
     </style>
@@ -38,6 +40,7 @@
             project-config="[[projectConfig]]"
             on-reply="_handleCommentReply"
             on-comment-discard="_handleCommentDiscard"
+            on-ack="_handleCommentAck"
             on-done="_handleCommentDone"></gr-diff-comment>
       </template>
     </div>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
index 90a4be1..de1c197 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
@@ -57,6 +57,10 @@
       '_commentsChanged(comments.splices)',
     ],
 
+    keyBindings: {
+      'e shift+e': '_handleEKey',
+    },
+
     attached: function() {
       this._getLoggedIn().then(function(loggedIn) {
         this._showActions = loggedIn;
@@ -88,12 +92,12 @@
       this._orderedComments = this._sortedComments(this.comments);
     },
 
-    _handleKey: function(e) {
+    _handleEKey: function(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-      if (e.keyCode === 69) { // 'e'
-        e.preventDefault();
-        this._expandCollapseComments(e.shiftKey);
-      }
+
+      // Don’t preventDefault in this case because it will render the event
+      // useless for other handlers (other gr-diff-comment-thread elements).
+      this._expandCollapseComments(e.detail.keyboardEvent.shiftKey);
     },
 
     _expandCollapseComments: function(actionIsCollapse) {
@@ -151,13 +155,25 @@
       if (e.detail.quote) {
         var msg = comment.message;
         var quoteStr = msg.split('\n').map(
-            function(line) { return ' > ' + line; }).join('\n') + '\n\n';
+            function(line) { return '> ' + line; }).join('\n') + '\n\n';
       }
       var reply = this._newReply(comment.id, comment.line, quoteStr);
       reply.__editing = true;
       this.push('comments', reply);
     },
 
+    _handleCommentAck: function(e) {
+      var comment = e.detail.comment;
+      var reply = this._newReply(comment.id, comment.line, 'Ack');
+      this.push('comments', reply);
+
+      // Allow the reply to render in the dom-repeat.
+      this.async(function() {
+        var commentEl = this._commentElWithDraftID(reply.__draftID);
+        commentEl.save();
+      }.bind(this), 1);
+    },
+
     _handleCommentDone: function(e) {
       var comment = e.detail.comment;
       var reply = this._newReply(comment.id, comment.line, 'Done');
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
index eb87f56..b694a75 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
@@ -172,7 +172,7 @@
           return c.__draft == true;
         });
         assert.equal(drafts.length, 1);
-        assert.equal(drafts[0].message, ' > is this a crossover episode!?\n\n');
+        assert.equal(drafts[0].message, '> is this a crossover episode!?\n\n');
         assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
         done();
       });
@@ -180,6 +180,23 @@
           {bubbles: false});
     });
 
+    test('ack', function(done) {
+      element.changeNum = '42';
+      element.patchNum = '1';
+      var commentEl = element.$$('gr-diff-comment');
+      assert.ok(commentEl);
+      commentEl.addEventListener('ack', function() {
+        var drafts = element._orderedComments.filter(function(c) {
+          return c.__draft == true;
+        });
+        assert.equal(drafts.length, 1);
+        assert.equal(drafts[0].message, 'Ack');
+        assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+        done();
+      });
+      commentEl.fire('ack', {comment: commentEl.comment}, {bubbles: false});
+    });
+
     test('done', function(done) {
       element.changeNum = '42';
       element.patchNum = '1';
@@ -280,10 +297,10 @@
         }];
       element.comments = comments;
       var expandCollapseStub = sinon.stub(element, '_expandCollapseComments');
-      MockInteractions.pressAndReleaseKeyOn(element, 69);  // 'e'
+      MockInteractions.pressAndReleaseKeyOn(element, 69, null, 'e');
       assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
 
-      MockInteractions.pressAndReleaseKeyOn(element, 69, 'shift');  // 'e'
+      MockInteractions.pressAndReleaseKeyOn(element, 69, 'shift', 'e');
       assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
       expandCollapseStub.restore();
     });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
index ebb1f87..a1c73db 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
@@ -18,7 +18,7 @@
 <link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
+<link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
 <link rel="import" href="../../shared/gr-storage/gr-storage.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
@@ -26,8 +26,9 @@
   <template>
     <style>
       :host {
-        background-color: #ffd;
         display: block;
+        font-family: var(--font-family);
+        margin: .7em 0;
         --iron-autogrow-textarea: {
           padding: 2px;
         };
@@ -38,17 +39,16 @@
       :host([disabled]) .container {
         opacity: .5;
       }
-      .header,
-      .message,
-      .actions {
-        padding: .5em .7em;
-      }
       .header {
         cursor: pointer;
         display: flex;
         font-family: 'Open Sans', sans-serif;
+        margin: 0.7em 0;
         padding-bottom: 0;
       }
+      .container.collapsed .header {
+        margin: 0;
+      }
       .headerMiddle {
         color: #666;
         flex: 1;
@@ -93,11 +93,12 @@
       .danger .action {
         margin-right: 0;
       }
-      .container:not(.draft) .actions :not(.reply):not(.quote):not(.done) {
+      .container:not(.draft) .actions :not(.reply):not(.quote):not(.ack):not(.done) {
         display: none;
       }
       .draft .reply,
       .draft .quote,
+      .draft .ack,
       .draft .done {
         display: none;
       }
@@ -111,6 +112,7 @@
       .editing .message,
       .editing .reply,
       .editing .quote,
+      .editing .ack,
       .editing .done,
       .editing .edit {
         display: none;
@@ -147,7 +149,7 @@
         white-space: nowrap;
       }
       #container.collapsed .actions,
-      #container.collapsed gr-linked-text,
+      #container.collapsed gr-formatted-text,
       #container.collapsed iron-autogrow-textarea {
         display: none;
       }
@@ -179,18 +181,19 @@
       <iron-autogrow-textarea
           id="editTextarea"
           class="editMessage"
+          autocomplete="on"
           disabled="{{disabled}}"
           rows="4"
           bind-value="{{_messageText}}"
           on-keydown="_handleTextareaKeydown"></iron-autogrow-textarea>
-      <gr-linked-text class="message"
-          pre
+      <gr-formatted-text class="message"
           content="[[comment.message]]"
           collapsed="[[collapsed]]"
-          config="[[projectConfig.commentlinks]]"></gr-linked-text>
+          config="[[projectConfig.commentlinks]]"></gr-formatted-text>
       <div class="actions" hidden$="[[!showActions]]">
         <gr-button class="action reply" on-tap="_handleReply">Reply</gr-button>
         <gr-button class="action quote" on-tap="_handleQuote">Quote</gr-button>
+        <gr-button class="action ack" on-tap="_handleAck">Ack</gr-button>
         <gr-button class="action done" on-tap="_handleDone">Done</gr-button>
         <gr-button class="action edit" on-tap="_handleEdit">Edit</gr-button>
         <gr-button class="action save" on-tap="_handleSave"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
index 410a813..72138ff 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
@@ -26,6 +26,12 @@
      */
 
     /**
+     * Fired when the Ack action is triggered.
+     *
+     * @event ack
+     */
+
+    /**
      * Fired when the Done action is triggered.
      *
      * @event done
@@ -119,12 +125,7 @@
       this.comment.message = this._messageText;
       this.disabled = true;
 
-      this.$.storage.eraseDraftComment({
-        changeNum: this.changeNum,
-        patchNum: this.patchNum,
-        path: this.comment.path,
-        line: this.comment.line,
-      });
+      this._eraseDraftComment();
 
       this._xhrPromise = this._saveDraft(this.comment).then(function(response) {
         this.disabled = false;
@@ -149,6 +150,15 @@
       }.bind(this));
     },
 
+    _eraseDraftComment: function() {
+      this.$.storage.eraseDraftComment({
+        changeNum: this.changeNum,
+        patchNum: this.patchNum,
+        path: this.comment.path,
+        line: this.comment.line,
+      });
+    },
+
     _commentChanged: function(comment) {
       this.editing = !!comment.__editing;
       if (this.editing) { // It's a new draft/reply, notify.
@@ -279,34 +289,39 @@
     },
 
     _handleReply: function(e) {
-      this._preventDefaultAndBlur(e);
+      e.preventDefault();
       this.fire('reply', this._getEventPayload(), {bubbles: false});
     },
 
     _handleQuote: function(e) {
-      this._preventDefaultAndBlur(e);
+      e.preventDefault();
       this.fire(
           'reply', this._getEventPayload({quote: true}), {bubbles: false});
     },
 
+    _handleAck: function(e) {
+      e.preventDefault();
+      this.fire('ack', this._getEventPayload(), {bubbles: false});
+    },
+
     _handleDone: function(e) {
-      this._preventDefaultAndBlur(e);
+      e.preventDefault();
       this.fire('done', this._getEventPayload(), {bubbles: false});
     },
 
     _handleEdit: function(e) {
-      this._preventDefaultAndBlur(e);
+      e.preventDefault();
       this._messageText = this.comment.message;
       this.editing = true;
     },
 
     _handleSave: function(e) {
-      this._preventDefaultAndBlur(e);
+      e.preventDefault();
       this.save();
     },
 
     _handleCancel: function(e) {
-      this._preventDefaultAndBlur(e);
+      e.preventDefault();
       if (this.comment.message == null || this.comment.message.length == 0) {
         this._fireDiscard();
         return;
@@ -321,12 +336,14 @@
     },
 
     _handleDiscard: function(e) {
-      this._preventDefaultAndBlur(e);
+      e.preventDefault();
       if (!this.comment.__draft) {
         throw Error('Cannot discard a non-draft comment.');
       }
       this.editing = false;
       this.disabled = true;
+      this._eraseDraftComment();
+
       if (!this.comment.id) {
         this.disabled = false;
         this._fireDiscard();
@@ -345,11 +362,6 @@
           }.bind(this));
     },
 
-    _preventDefaultAndBlur: function(e) {
-      e.preventDefault();
-      Polymer.dom(e).rootTarget.blur();
-    },
-
     _saveDraft: function(draft) {
       return this.$.restAPI.saveDiffDraft(this.changeNum, this.patchNum, draft);
     },
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
index 0ad3e11..f562d3a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
@@ -73,8 +73,8 @@
     test('collapsible comments', function() {
       // When a comment (not draft) is loaded, it should be collapsed
       assert.isTrue(element.collapsed);
-      assert.isFalse(isVisible(element.$$('gr-linked-text')),
-          'gr-linked-text is not visible');
+      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is not visible');
       assert.isFalse(isVisible(element.$$('.actions')),
           'actions are not visible');
       assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
@@ -88,8 +88,8 @@
       // When the header row is clicked, the comment should expand
       MockInteractions.tap(element.$.header);
       assert.isFalse(element.collapsed);
-      assert.isTrue(isVisible(element.$$('gr-linked-text')),
-          'gr-linked-text is visible');
+      assert.isTrue(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is visible');
       assert.isTrue(isVisible(element.$$('.actions')),
           'actions are visible');
       assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
@@ -115,6 +115,13 @@
       MockInteractions.tap(element.$$('.quote'));
     });
 
+    test('proper event fires on ack', function(done) {
+      element.addEventListener('ack', function(e) {
+        done();
+      });
+      MockInteractions.tap(element.$$('.ack'));
+    });
+
     test('proper event fires on done', function(done) {
       element.addEventListener('done', function(e) {
         done();
@@ -135,8 +142,8 @@
 
     test('comment expand and collapse', function() {
       element.collapsed = true;
-      assert.isFalse(isVisible(element.$$('gr-linked-text')),
-          'gr-linked-text is not visible');
+      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is not visible');
       assert.isFalse(isVisible(element.$$('.actions')),
           'actions are not visible');
       assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
@@ -146,8 +153,8 @@
 
       element.collapsed = false;
       assert.isFalse(element.collapsed);
-      assert.isTrue(isVisible(element.$$('gr-linked-text')),
-          'gr-linked-text is visible');
+      assert.isTrue(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is visible');
       assert.isTrue(isVisible(element.$$('.actions')),
           'actions are visible');
       assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
@@ -175,6 +182,7 @@
 
   suite('gr-diff-comment draft tests', function() {
     var element;
+    var sandbox;
 
     setup(function() {
       stub('gr-rest-api-interface', {
@@ -212,6 +220,11 @@
         path: '/path/to/file',
         line: 5,
       };
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(function() {
+      sandbox.restore();
     });
 
     test('button visibility states', function() {
@@ -227,6 +240,7 @@
       assert.isFalse(isVisible(element.$$('.cancel')), 'cancel is not visible');
       assert.isFalse(isVisible(element.$$('.reply')), 'reply is not visible');
       assert.isFalse(isVisible(element.$$('.quote')), 'quote is not visible');
+      assert.isFalse(isVisible(element.$$('.ack')), 'ack is not visible');
       assert.isFalse(isVisible(element.$$('.done')), 'done is not visible');
 
       element.editing = true;
@@ -236,6 +250,7 @@
       assert.isFalse(isVisible(element.$$('.cancel')), 'cancel is visible');
       assert.isFalse(isVisible(element.$$('.reply')), 'reply is not visible');
       assert.isFalse(isVisible(element.$$('.quote')), 'quote is not visible');
+      assert.isFalse(isVisible(element.$$('.ack')), 'ack is not visible');
       assert.isFalse(isVisible(element.$$('.done')), 'done is not visible');
 
       element.draft = false;
@@ -247,6 +262,7 @@
       assert.isFalse(isVisible(element.$$('.cancel')), 'cancel is not visible');
       assert.isTrue(isVisible(element.$$('.reply')), 'reply is visible');
       assert.isTrue(isVisible(element.$$('.quote')), 'quote is visible');
+      assert.isTrue(isVisible(element.$$('.ack')), 'ack is visible');
       assert.isTrue(isVisible(element.$$('.done')), 'done is visible');
 
       element.comment.id = 'foo';
@@ -257,8 +273,8 @@
 
     test('collapsible drafts', function() {
       assert.isTrue(element.collapsed);
-      assert.isFalse(isVisible(element.$$('gr-linked-text')),
-          'gr-linked-text is not visible');
+      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is not visible');
       assert.isFalse(isVisible(element.$$('.actions')),
           'actions are not visible');
       assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
@@ -268,8 +284,8 @@
 
       MockInteractions.tap(element.$.header);
       assert.isFalse(element.collapsed);
-      assert.isTrue(isVisible(element.$$('gr-linked-text')),
-          'gr-linked-text is visible');
+      assert.isTrue(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is visible');
       assert.isTrue(isVisible(element.$$('.actions')),
           'actions are visible');
       assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
@@ -281,8 +297,8 @@
       // and also textarea
       MockInteractions.tap(element.$$('.edit'));
       assert.isFalse(element.collapsed);
-      assert.isFalse(isVisible(element.$$('gr-linked-text')),
-          'gr-linked-text is not visible');
+      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is not visible');
       assert.isTrue(isVisible(element.$$('.actions')),
           'actions are visible');
       assert.isTrue(isVisible(element.$$('iron-autogrow-textarea')),
@@ -294,8 +310,8 @@
       // and header middle content should be visible
       MockInteractions.tap(element.$.header);
       assert.isTrue(element.collapsed);
-      assert.isFalse(isVisible(element.$$('gr-linked-text')),
-          'gr-linked-text is not visible');
+      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is not visible');
       assert.isFalse(isVisible(element.$$('.actions')),
           'actions are not visible');
       assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
@@ -306,8 +322,8 @@
       // When toggle again, textarea should remain open in the state it was
       // before
       MockInteractions.tap(element.$.header);
-      assert.isFalse(isVisible(element.$$('gr-linked-text')),
-          'gr-linked-text is not visible');
+      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is not visible');
       assert.isTrue(isVisible(element.$$('.actions')),
           'actions are visible');
       assert.isTrue(isVisible(element.$$('iron-autogrow-textarea')),
@@ -322,6 +338,8 @@
       assert.isTrue(element.editing);
 
       element._messageText = '';
+      var eraseMessageDraftSpy = sandbox.spy(element, '_eraseDraftComment');
+
       // Save should be disabled on an empty message.
       var disabled = element.$$('.save').hasAttribute('disabled');
       assert.isTrue(disabled, 'save button should be disabled.');
@@ -335,18 +353,30 @@
       var numDiscardEvents = 0;
       element.addEventListener('comment-discard', function(e) {
         numDiscardEvents++;
-        if (numDiscardEvents == 3) {
+        assert.isFalse(eraseMessageDraftSpy.called);
+        if (numDiscardEvents === 2) {
           assert.isFalse(updateStub.called);
           done();
         }
       });
       MockInteractions.tap(element.$$('.cancel'));
-      MockInteractions.tap(element.$$('.discard'));
       element.flushDebouncer('fire-update');
       element._messageText = '';
       MockInteractions.pressAndReleaseKeyOn(element.$.editTextarea, 27); // esc
     });
 
+    test('draft discard removes message from storage', function(done) {
+      element._messageText = '';
+      var eraseMessageDraftSpy = sandbox.spy(element, '_eraseDraftComment');
+
+      var numDiscardEvents = 0;
+      element.addEventListener('comment-discard', function(e) {
+        assert.isTrue(eraseMessageDraftSpy.called);
+        done();
+      });
+      MockInteractions.tap(element.$$('.discard'));
+    });
+
     test('ctrl+s saves comment', function(done) {
       var stub = sinon.stub(element, 'save', function() {
         assert.isTrue(stub.called);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
index c8eea3c..2d0786a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
@@ -21,7 +21,7 @@
   <template>
     <gr-cursor-manager
         id="cursorManager"
-        scroll="[[_scrollBehavior]]"
+        scroll-behavior="[[_scrollBehavior]]"
         cursor-target-class="target-row"
         target="{{diffRow}}"></gr-cursor-manager>
   </template>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js
index ec21fd1..bb5b938 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js
@@ -32,7 +32,11 @@
      * @return {Number} The length of the text.
      */
     getLength: function(node) {
-      return node.textContent.replace(REGEX_ASTRAL_SYMBOL, '_').length;
+      return this.getStringLength(node.textContent);
+    },
+
+    getStringLength: function(str) {
+      return str.replace(REGEX_ASTRAL_SYMBOL, '_').length;
     },
 
     /**
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
index 1188dd3..c39a89f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
@@ -121,6 +121,11 @@
             on-tap="_handleShowTabsTap">
       </div>
       <div class="pref">
+        <label for="showTrailingWhitespaceInput">Show Trailing Whitespace</label>
+        <input is="iron-input" type="checkbox" id="showTrailingWhitespaceInput"
+            on-tap="_handleShowTrailingWhitespaceTap">
+      </div>
+      <div class="pref">
         <label for="syntaxHighlightInput">Syntax highlighting</label>
         <input is="iron-input" type="checkbox" id="syntaxHighlightInput"
             on-tap="_handleSyntaxHighlightTap">
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
index 2d6bd8c..fd2a6f5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
@@ -72,6 +72,7 @@
       this._newPrefs = Object.assign({}, prefs);
       this.$.contextSelect.value = prefs.context;
       this.$.showTabsInput.checked = prefs.show_tabs;
+      this.$.showTrailingWhitespaceInput.checked = prefs.show_whitespace_errors;
       this.$.lineWrappingInput.checked = prefs.line_wrapping;
       this.$.syntaxHighlightInput.checked = prefs.syntax_highlighting;
     },
@@ -91,6 +92,11 @@
       this.set('_newPrefs.show_tabs', Polymer.dom(e).rootTarget.checked);
     },
 
+    _handleShowTrailingWhitespaceTap: function(e) {
+      this.set('_newPrefs.show_whitespace_errors',
+          Polymer.dom(e).rootTarget.checked);
+    },
+
     _handleSyntaxHighlightTap: function(e) {
       this.set('_newPrefs.syntax_highlighting',
           Polymer.dom(e).rootTarget.checked);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html
index 9ec8a02..999f005 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html
@@ -45,6 +45,7 @@
         line_length: 100,
         show_tabs: true,
         tab_size: 8,
+        show_whitespace_errors: true,
         syntax_highlighting: true,
       };
       assert.deepEqual(element.prefs, element._newPrefs);
@@ -55,6 +56,7 @@
       element.$.fontSizeInput.bindValue = 10;
       element.$.tabSizeInput.bindValue = 4;
       MockInteractions.tap(element.$.showTabsInput);
+      MockInteractions.tap(element.$.showTrailingWhitespaceInput);
       MockInteractions.tap(element.$.syntaxHighlightInput);
       MockInteractions.tap(element.$.lineWrappingInput);
 
@@ -63,6 +65,7 @@
       assert.equal(element._newPrefs.line_length, 80);
       assert.equal(element._newPrefs.tab_size, 4);
       assert.isFalse(element._newPrefs.show_tabs);
+      assert.isFalse(element._newPrefs.show_whitespace_errors);
       assert.isTrue(element._newPrefs.line_wrapping);
       assert.isFalse(element._newPrefs.syntax_highlighting);
     });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
index 24887e0..6ba3ff8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
@@ -59,7 +59,7 @@
         return;
       }
       var commentSelected =
-          e.target.parentNode.classList.contains('gr-diff-comment');
+          this._elementDescendedFromClass(e.target, 'gr-diff-comment');
       var side = this.diffBuilder.getSideByLineEl(lineEl);
       var targetClasses = [];
       targetClasses.push(side === 'left' ?
@@ -215,15 +215,6 @@
     _getCommentLines: function(sel, side) {
       var range = sel.getRangeAt(0);
       var content = [];
-      // Fall back to default copy behavior if the selection lies within one
-      // comment body.
-      if (range.startContainer === range.endContainer) {
-        return;
-      }
-      if (this._elementDescendedFromClass(range.commonAncestorContainer,
-          'message')) {
-        return;
-      }
       // Query the diffElement for comments.
       var messages = this.diffBuilder.diffElement.querySelectorAll(
           '.side-by-side [data-side="' + side +
@@ -233,15 +224,25 @@
         var el = messages[i];
         // Check if the comment element exists inside the selection.
         if (sel.containsNode(el, true)) {
-          content.push(el.textContent);
+          // Padded elements require newlines for accurate spacing.
+          if (el.parentElement.id === 'container' ||
+              el.parentElement.nodeName === 'BLOCKQUOTE') {
+            if (content.length && content[content.length - 1] !== '') {
+              content.push('');
+            }
+          }
+
+          if (!el.children.length) {
+            content.push(el.textContent);
+          }
         }
       }
-      // Deal with offsets.
-      content[0] = content[0].substring(range.startOffset);
+
       if (range.endOffset) {
         content[content.length - 1] =
             content[content.length - 1].substring(0, range.endOffset);
       }
+      content[0] = content[0].substring(range.startOffset);
       return content.join('\n');
     },
   });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
index d6a6298..a5c26e1 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
@@ -34,8 +34,8 @@
             <div class="contentText" data-side="left">ba ba</div>
             <div data-side="left">
               <div class="gr-diff-comment-thread">
-                <div class="message">
-                  <span>This is a comment</span>
+                <div class="gr-formatted-text message">
+                  <span class="gr-linked-text">This is a comment</span>
                 </div>
               </div>
             </div>
@@ -55,8 +55,8 @@
             <div class="contentText" data-side="right">more more more</div>
             <div data-side="right">
               <div class="gr-diff-comment-thread">
-                <div class="message">
-                  <span>This is a comment on the right</span>
+                <div class="gr-formatted-text message">
+                  <span class="gr-linked-text">This is a comment on the right</span>
                 </div>
               </div>
             </div>
@@ -68,8 +68,8 @@
             <div class="contentText" data-side="left">ga ga</div>
             <div data-side="left">
               <div class="gr-diff-comment-thread">
-                <div class="message">
-                  <span>This is a different comment</span>
+                <div class="gr-formatted-text message">
+                  <span class="gr-linked-text">This is a different comment</span>
                 </div>
               </div>
             </div>
@@ -213,13 +213,13 @@
       element.classList.remove('selected-right');
 
       var selection = window.getSelection();
+      selection.removeAllRanges();
       var range = document.createRange();
       range.setStart(element.querySelector('div.contentText').firstChild, 3);
       range.setEnd(
           element.querySelectorAll('div.contentText')[4].firstChild, 2);
       selection.addRange(range);
       assert.equal(element._getSelectedText('left'), 'ba\nzin\nga');
-      selection.removeAllRanges();
     });
 
     test('copies comments', function() {
@@ -227,14 +227,15 @@
       element.classList.add('selected-comment');
       element.classList.remove('selected-right');
       var selection = window.getSelection();
+      selection.removeAllRanges();
       var range = document.createRange();
-      range.setStart(element.querySelector('.message *').firstChild, 3);
+      range.setStart(
+          element.querySelector('.gr-formatted-text *').firstChild, 3);
       range.setEnd(
-          element.querySelectorAll('.message *')[2].firstChild, 16);
+          element.querySelectorAll('.gr-formatted-text *')[2].firstChild, 16);
       selection.addRange(range);
       assert.equal('s is a comment\nThis is a differ',
           element._getSelectedText('left', true));
-      selection.removeAllRanges();
     });
 
     test('defers to default behavior for textarea', function() {
@@ -257,6 +258,7 @@
       element.classList.remove('selected-left');
 
       var selection = window.getSelection();
+      selection.removeAllRanges();
       var range = document.createRange();
       range.setStart(
           element.querySelectorAll('div.contentText')[1].firstChild, 4);
@@ -264,7 +266,6 @@
           element.querySelectorAll('div.contentText')[1].firstChild, 10);
       selection.addRange(range);
       assert.equal(element._getSelectedText('right'), ' other');
-      selection.removeAllRanges();
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
index 69c8ef5..b6471e7 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
@@ -203,7 +203,8 @@
               change-num="[[_changeNum]]"
               patch-range="[[_patchRange]]"
               files-weblinks="[[_filesWeblinks]]"
-              available-patches="[[_computeAvailablePatches(_change.revisions)]]">
+              available-patches="[[_computeAvailablePatches(_change.revisions)]]"
+              revisions="[[_change.revisions]]">
           </gr-patch-range-select>
           <span class="separator">/</span>
           <a class="downloadLink"
@@ -216,8 +217,7 @@
               id="modeSelect"
               is="gr-select"
               bind-value="{{changeViewState.diffMode}}"
-              hidden$="[[_computeModeSelectHidden(_isImageDiff)]]"
-              on-change="_handleDropdownChange">
+              hidden$="[[_computeModeSelectHidden(_isImageDiff)]]">
             <option value="SIDE_BY_SIDE">Side By Side</option>
             <option value="UNIFIED_DIFF">Unified</option>
           </select>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index 44204fd..f8f646a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -100,6 +100,22 @@
       '_getFiles(_changeNum, _patchRange.*)',
     ],
 
+    keyBindings: {
+      'esc': '_handleEscKey',
+      'shift+left': '_handleShiftLeftKey',
+      'shift+right': '_handleShiftRightKey',
+      'up k': '_handleUpKey',
+      'down j': '_handleDownKey',
+      'c': '_handleCKey',
+      '[': '_handleLeftBracketKey',
+      ']': '_handleRightBracketKey',
+      'n shift+n': '_handleNKey',
+      'p shift+p': '_handlePKey',
+      'a shift+a': '_handleAKey',
+      'u': '_handleUKey',
+      ',': '_handleCommaKey',
+    },
+
     attached: function() {
       this._getLoggedIn().then(function(loggedIn) {
         this._loggedIn = loggedIn;
@@ -185,103 +201,130 @@
           this._patchRange.patchNum, this._path, reviewed);
     },
 
-    _checkForModifiers: function(e) {
-      return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || false;
-    },
-
-    _handleKey: function(e) {
+    _handleEscKey: function(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
-      switch (e.keyCode) {
-        case 27: // escape
-          e.preventDefault();
-          this.$.diff.displayLine = false;
-          break;
-        case 37: // left
-          if (e.shiftKey) {
-            e.preventDefault();
-            this.$.cursor.moveLeft();
-          }
-          break;
-        case 39: // right
-          if (e.shiftKey) {
-            e.preventDefault();
-            this.$.cursor.moveRight();
-          }
-          break;
-        case 40: // down
-        case 74: // 'j'
-          e.preventDefault();
-          this.$.diff.displayLine = true;
-          this.$.cursor.moveDown();
-          break;
-        case 38: // up
-        case 75: // 'k'
-          e.preventDefault();
-          this.$.diff.displayLine = true;
-          this.$.cursor.moveUp();
-          break;
-        case 67: // 'c'
-          if (this._checkForModifiers(e)) { return; }
-          if (!this.$.diff.isRangeSelected()) {
-            e.preventDefault();
-            var line = this.$.cursor.getTargetLineElement();
-            if (line) {
-              this.$.diff.addDraftAtLine(line);
-            }
-          }
-          break;
-        case 219:  // '['
-          e.preventDefault();
-          this._navToFile(this._path, this._fileList, -1);
-          break;
-        case 221:  // ']'
-          e.preventDefault();
-          this._navToFile(this._path, this._fileList, 1);
-          break;
-        case 78:  // 'n'
-          e.preventDefault();
-          if (e.shiftKey) {
-            this.$.cursor.moveToNextCommentThread();
-          } else {
-            this.$.cursor.moveToNextChunk();
-          }
-          break;
-        case 80:  // 'p'
-          e.preventDefault();
-          if (e.shiftKey) {
-            this.$.cursor.moveToPreviousCommentThread();
-          } else {
-            this.$.cursor.moveToPreviousChunk();
-          }
-          break;
-        case 65:  // 'a'
-          if (e.shiftKey) { // Hide left diff.
-            e.preventDefault();
-            this.$.diff.toggleLeftDiff();
-            break;
-          }
+      e.preventDefault();
+      this.$.diff.displayLine = false;
+    },
 
-          if (!this._loggedIn) { break; }
+    _handleShiftLeftKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
-          this.set('changeViewState.showReplyDialog', true);
-          /* falls through */ // required by JSHint
-        case 85:  // 'u'
-          if (this._changeNum && this._patchRange.patchNum) {
-            e.preventDefault();
-            page.show(this._getChangePath(
-                this._changeNum,
-                this._patchRange,
-                this._change && this._change.revisions));
-          }
-          break;
-        case 188:  // ','
-          e.preventDefault();
-          this._openPrefs();
-          break;
+      e.preventDefault();
+      this.$.cursor.moveLeft();
+    },
+
+    _handleShiftRightKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      this.$.cursor.moveRight();
+    },
+
+    _handleUpKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      this.$.diff.displayLine = true;
+      this.$.cursor.moveUp();
+    },
+
+    _handleDownKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      this.$.diff.displayLine = true;
+      this.$.cursor.moveDown();
+    },
+
+    _handleCKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+      if (this.$.diff.isRangeSelected()) { return; }
+      if (this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      var line = this.$.cursor.getTargetLineElement();
+      if (line) {
+        this.$.diff.addDraftAtLine(line);
       }
     },
 
+    _handleLeftBracketKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      this._navToFile(this._path, this._fileList, -1);
+    },
+
+    _handleRightBracketKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      this._navToFile(this._path, this._fileList, 1);
+    },
+
+    _handleNKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      if (e.detail.keyboardEvent.shiftKey) {
+        this.$.cursor.moveToNextCommentThread();
+      } else {
+        this.$.cursor.moveToNextChunk();
+      }
+    },
+
+    _handlePKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      if (e.detail.keyboardEvent.shiftKey) {
+        this.$.cursor.moveToPreviousCommentThread();
+      } else {
+        this.$.cursor.moveToPreviousChunk();
+      }
+    },
+
+    _handleAKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      if (e.detail.keyboardEvent.shiftKey) { // Hide left diff.
+        e.preventDefault();
+        this.$.diff.toggleLeftDiff();
+        return;
+      }
+
+      if (!this._loggedIn) { return; }
+
+      this.set('changeViewState.showReplyDialog', true);
+      e.preventDefault();
+      this._navToChangeView();
+    },
+
+    _handleUKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      this._navToChangeView();
+    },
+
+    _handleCommaKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      this._openPrefs();
+    },
+
+    _navToChangeView: function() {
+      if (!this._changeNum || !this._patchRange.patchNum) { return; }
+
+      page.show(this._getChangePath(
+          this._changeNum,
+          this._patchRange,
+          this._change && this._change.revisions));
+    },
+
     _navToFile: function(path, fileList, direction) {
       var url = this._computeNavLinkURL(path, fileList, direction);
       if (!url) { return; }
@@ -556,10 +599,6 @@
       history.replaceState(null, null, '#' + this.$.cursor.getAddress());
     },
 
-    _handleDropdownChange: function(e) {
-      e.target.blur();
-    },
-
     _computeDownloadLink: function(changeNum, patchRange, path) {
       var url = this.changeBaseURL(changeNum, patchRange.patchNum);
       url += '/patch?zip&path=' + encodeURIComponent(path);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
index e423e45..c6ccb1b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
@@ -62,7 +62,7 @@
 
     test('toggle left diff with a hotkey', function() {
       var toggleLeftDiffStub = sandbox.stub(element.$.diff, 'toggleLeftDiff');
-      MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift');  // 'a'
+      MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
       assert.isTrue(toggleLeftDiffStub.calledOnce);
     });
 
@@ -82,29 +82,29 @@
       element.changeViewState.selectedFileIndex = 1;
 
       var showStub = sandbox.stub(page, 'show');
-      MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'u'
+      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
       assert(showStub.lastCall.calledWithExactly('/c/42/'),
           'Should navigate to /c/42/');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 221);  // ']'
+      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
       assert(showStub.lastCall.calledWithExactly('/c/42/10/wheatley.md'),
           'Should navigate to /c/42/10/wheatley.md');
       element._path = 'wheatley.md';
       assert.equal(element.changeViewState.selectedFileIndex, 2);
 
-      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(showStub.lastCall.calledWithExactly('/c/42/10/glados.txt'),
           'Should navigate to /c/42/10/glados.txt');
       element._path = 'glados.txt';
       assert.equal(element.changeViewState.selectedFileIndex, 1);
 
-      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(showStub.lastCall.calledWithExactly('/c/42/10/chell.go'),
           'Should navigate to /c/42/10/chell.go');
       element._path = 'chell.go';
       assert.equal(element.changeViewState.selectedFileIndex, 0);
 
-      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(showStub.lastCall.calledWithExactly('/c/42/'),
           'Should navigate to /c/42/');
       assert.equal(element.changeViewState.selectedFileIndex, 0);
@@ -112,33 +112,33 @@
       var showPrefsStub = sandbox.stub(element.$.prefsOverlay, 'open',
           function() { return Promise.resolve({}); });
 
-      MockInteractions.pressAndReleaseKeyOn(element, 188);  // ','
+      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
       assert(showPrefsStub.calledOnce);
 
       var scrollStub = sandbox.stub(element.$.cursor, 'moveToNextChunk');
-      MockInteractions.pressAndReleaseKeyOn(element, 78);  // 'n'
+      MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
       assert(scrollStub.calledOnce);
 
       scrollStub = sandbox.stub(element.$.cursor, 'moveToPreviousChunk');
-      MockInteractions.pressAndReleaseKeyOn(element, 80);  // 'p'
+      MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
       assert(scrollStub.calledOnce);
 
       scrollStub = sandbox.stub(element.$.cursor, 'moveToNextCommentThread');
-      MockInteractions.pressAndReleaseKeyOn(element, 78, ['shift']);  // 'N'
+      MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
       assert(scrollStub.calledOnce);
 
       scrollStub = sandbox.stub(element.$.cursor,
           'moveToPreviousCommentThread');
-      MockInteractions.pressAndReleaseKeyOn(element, 80, ['shift']);  // 'P'
+      MockInteractions.pressAndReleaseKeyOn(element, 80, 'shift', 'p');
       assert(scrollStub.calledOnce);
 
       var computeContainerClassStub = sandbox.stub(element.$.diff,
           '_computeContainerClass');
-      MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'j'
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
       assert(computeContainerClassStub.lastCall.calledWithExactly(
           false, 'SIDE_BY_SIDE', true));
 
-      MockInteractions.pressAndReleaseKeyOn(element, 27);  // 'escape'
+      MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
       assert(computeContainerClassStub.lastCall.calledWithExactly(
           false, 'SIDE_BY_SIDE', false));
     });
@@ -175,39 +175,39 @@
 
       var showStub = sandbox.stub(page, 'show');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
       assert.isTrue(showStub.notCalled, 'The `a` keyboard shortcut should ' +
           'only work when the user is logged in.');
       assert.isNull(window.sessionStorage.getItem(
           'changeView.showReplyDialog'));
 
       element._loggedIn = true;
-      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
       assert.isTrue(element.changeViewState.showReplyDialog);
 
       assert(showStub.lastCall.calledWithExactly('/c/42/5..10'),
           'Should navigate to /c/42/5..10');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'u'
+      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
       assert(showStub.lastCall.calledWithExactly('/c/42/5..10'),
           'Should navigate to /c/42/5..10');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 221);  // ']'
+      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
       assert(showStub.lastCall.calledWithExactly('/c/42/5..10/wheatley.md'),
           'Should navigate to /c/42/5..10/wheatley.md');
       element._path = 'wheatley.md';
 
-      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(showStub.lastCall.calledWithExactly('/c/42/5..10/glados.txt'),
           'Should navigate to /c/42/5..10/glados.txt');
       element._path = 'glados.txt';
 
-      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(showStub.lastCall.calledWithExactly('/c/42/5..10/chell.go'),
           'Should navigate to /c/42/5..10/chell.go');
       element._path = 'chell.go';
 
-      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(showStub.lastCall.calledWithExactly('/c/42/5..10'),
           'Should navigate to /c/42/5..10');
     });
@@ -229,39 +229,39 @@
 
       var showStub = sandbox.stub(page, 'show');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
       assert.isTrue(showStub.notCalled, 'The `a` keyboard shortcut should ' +
           'only work when the user is logged in.');
       assert.isNull(window.sessionStorage.getItem(
           'changeView.showReplyDialog'));
 
       element._loggedIn = true;
-      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
       assert.isTrue(element.changeViewState.showReplyDialog);
 
       assert(showStub.lastCall.calledWithExactly('/c/42/1'),
           'Should navigate to /c/42/1');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'u'
+      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
       assert(showStub.lastCall.calledWithExactly('/c/42/1'),
           'Should navigate to /c/42/1');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 221);  // ']'
+      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
       assert(showStub.lastCall.calledWithExactly('/c/42/1/wheatley.md'),
           'Should navigate to /c/42/1/wheatley.md');
       element._path = 'wheatley.md';
 
-      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(showStub.lastCall.calledWithExactly('/c/42/1/glados.txt'),
           'Should navigate to /c/42/1/glados.txt');
       element._path = 'glados.txt';
 
-      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(showStub.lastCall.calledWithExactly('/c/42/1/chell.go'),
           'Should navigate to /c/42/1/chell.go');
       element._path = 'chell.go';
 
-      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(showStub.lastCall.calledWithExactly('/c/42/1'),
           'Should navigate to /c/42/1');
     });
@@ -278,39 +278,39 @@
 
       var showStub = sandbox.stub(page, 'show');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
       assert.isTrue(showStub.notCalled, 'The `a` keyboard shortcut should ' +
           'only work when the user is logged in.');
       assert.isNull(window.sessionStorage.getItem(
           'changeView.showReplyDialog'));
 
       element._loggedIn = true;
-      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
       assert.isTrue(element.changeViewState.showReplyDialog);
 
       assert(showStub.lastCall.calledWithExactly('/c/42/1'),
           'Should navigate to /c/42/1');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'u'
+      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
       assert(showStub.lastCall.calledWithExactly('/c/42/1'),
           'Should navigate to /c/42/1');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 221);  // ']'
+      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
       assert(showStub.lastCall.calledWithExactly('/c/42/1/wheatley.md'),
           'Should navigate to /c/42/1/wheatley.md');
       element._path = 'wheatley.md';
 
-      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(showStub.lastCall.calledWithExactly('/c/42/1/glados.txt'),
           'Should navigate to /c/42/1/glados.txt');
       element._path = 'glados.txt';
 
-      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(showStub.lastCall.calledWithExactly('/c/42/1/chell.go'),
           'Should navigate to /c/42/1/chell.go');
       element._path = 'chell.go';
 
-      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(showStub.lastCall.calledWithExactly('/c/42/1'),
           'Should navigate to /c/42/1');
     });
@@ -457,7 +457,6 @@
     test('diff mode selector correctly toggles the diff', function() {
       var select = element.$.modeSelect;
       var diffDisplay = element.$.diff;
-      var blurSpy = sandbox.spy(select, 'blur');
       element._userPrefs = {diff_view: 'SIDE_BY_SIDE'};
 
       // The mode selected in the view state reflects the selected option.
@@ -477,7 +476,6 @@
       assert.equal(element._getDiffViewMode(), newMode);
       assert.equal(element._getDiffViewMode(), select.value);
       assert.equal(element._getDiffViewMode(), diffDisplay.viewMode);
-      assert(blurSpy.called, 'select should be blurred after selection');
     });
 
     test('diff mode selector initializes from preferences', function() {
@@ -557,14 +555,6 @@
       assert.equal(element.$.cursor.side, 'left');
     });
 
-    test('_checkForModifiers', function() {
-      assert.isTrue(element._checkForModifiers({altKey: true}));
-      assert.isTrue(element._checkForModifiers({ctrlKey: true}));
-      assert.isTrue(element._checkForModifiers({metaKey: true}));
-      assert.isTrue(element._checkForModifiers({shiftKey: true}));
-      assert.isFalse(element._checkForModifiers({}));
-    });
-
     test('_shortenPath with long path should add ellipsis', function() {
       var path =
           'level1/level2/level3/level4/file.js';
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
index 2b89a7a..313fa2a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -165,6 +165,10 @@
         /* >> character */
         content: '\00BB';
       }
+      .trailing-whitespace {
+        border-radius: .4em;
+        background-color: #FF9AD2;
+      }
     </style>
     <style include="gr-theme-default"></style>
     <div class$="[[_computeContainerClass(_loggedIn, viewMode, displayLine)]]"
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
index 54bccb3..fe9efd6 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
@@ -13,8 +13,10 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../shared/gr-select/gr-select.html">
+
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-select/gr-select.html">
 
 <dom-module id="gr-patch-range-select">
   <template>
@@ -25,6 +27,9 @@
       .patchRange {
         display: inline-block;
       }
+      select {
+        max-width: 25em;
+      }
     </style>
     Patch set:
     <span class="patchRange">
@@ -33,7 +38,10 @@
         <option value="PARENT">Base</option>
         <template is="dom-repeat" items="{{availablePatches}}" as="patchNum">
           <option value$="[[patchNum]]"
-              disabled$="[[_computeLeftDisabled(patchNum, patchRange)]]">[[patchNum]]</option>
+              disabled$="[[_computeLeftDisabled(patchNum, patchRange)]]">
+            [[patchNum]]
+            [[_computePatchSetDescription(revisions, patchNum)]]
+          </option>
         </template>
       </select>
     </span>
@@ -49,7 +57,10 @@
           on-change="_handlePatchChange" is="gr-select">
         <template is="dom-repeat" items="{{availablePatches}}" as="patchNum">
           <option value$="[[patchNum]]"
-              disabled$="[[_computeRightDisabled(patchNum, patchRange)]]">[[patchNum]]</option>
+              disabled$="[[_computeRightDisabled(patchNum, patchRange)]]">
+            [[patchNum]]
+            [[_computePatchSetDescription(revisions, patchNum)]]
+          </option>
         </template>
       </select>
       <span is="dom-if" if="[[filesWeblinks.meta_b]]">
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
index 350429f..58a3b00 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
@@ -24,12 +24,15 @@
       path: String,
       patchRange: {
         type: Object,
-        observer: '_updateSelected'
+        observer: '_updateSelected',
       },
+      revisions: Object,
       _rightSelected: String,
       _leftSelected: String,
     },
 
+    behaviors: [Gerrit.PatchSetBehavior],
+
     _updateSelected: function() {
       this._rightSelected = this.patchRange.patchNum;
       this._leftSelected = this.patchRange.basePatchNum;
@@ -67,5 +70,10 @@
     _synchronizeSelectionLeft: function() {
       this.$.leftPatchSelect.value = this._leftSelected;
     },
+
+    _computePatchSetDescription: function(revisions, patchNum) {
+      var rev = this.getRevisionNumber(revisions, patchNum);
+      return (rev && rev.description) ? rev.description : '';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
index 7496e59..90c37cd 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
@@ -32,7 +32,7 @@
       _commentMap: {
         type: Object,
         value: function() { return {left: [], right: []}; },
-      }
+      },
     },
 
     observers: [
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
index 568b5e0..3046534 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
@@ -51,6 +51,10 @@
       'mousedown': '_handleMouseDown', // See https://crbug.com/gerrit/4767
     },
 
+    keyBindings: {
+      'c': '_handleCKey',
+    },
+
     placeAbove: function(el) {
       var rect = this._getTargetBoundingRect(el);
       var boxRect = this.getBoundingClientRect();
@@ -74,17 +78,12 @@
       return rect;
     },
 
-    _checkForModifiers: function(e) {
-      return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || false;
-    },
-
-    _handleKey: function(e) {
+    _handleCKey: function(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-      if (e.keyCode === 67) { // 'c'
-        if (this._checkForModifiers(e)) { return; }
-        e.preventDefault();
-        this._fireCreateComment();
-      }
+      if (this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      this._fireCreateComment();
     },
 
     _handleMouseDown: function(e) {
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
index c12966d..79ff2a5 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
@@ -49,12 +49,12 @@
     });
 
     test('ignores regular keys', function() {
-      MockInteractions.pressAndReleaseKeyOn(document.body, 27); // 'esc'
+      MockInteractions.pressAndReleaseKeyOn(document.body, 27, null, 'esc');
       assert.isFalse(element.fire.called);
     });
 
     test('reacts to hotkey', function() {
-      MockInteractions.pressAndReleaseKeyOn(document.body, 67); // 'c'
+      MockInteractions.pressAndReleaseKeyOn(document.body, 67, null, 'c');
       assert.isTrue(element.fire.called);
     });
 
@@ -68,7 +68,7 @@
       };
       element.side = 'left';
       element.range = range;
-      MockInteractions.pressAndReleaseKeyOn(document.body, 67); // 'c'
+      MockInteractions.pressAndReleaseKeyOn(document.body, 67, null, 'c');
       assert(element.fire.calledWithExactly(
           'create-comment',
           {
@@ -117,13 +117,5 @@
         document.createRange.restore();
       });
     });
-
-    test('_checkForModifiers', function() {
-      assert.isTrue(element._checkForModifiers({altKey: true}));
-      assert.isTrue(element._checkForModifiers({ctrlKey: true}));
-      assert.isTrue(element._checkForModifiers({metaKey: true}));
-      assert.isTrue(element._checkForModifiers({shiftKey: true}));
-      assert.isFalse(element._checkForModifiers({}));
-    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
index 1103c03..eca40a6 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
@@ -78,6 +78,10 @@
     'gr-diff gr-syntax gr-syntax-selector-class': true,
   };
 
+  var CPP_DIRECTIVE_WITH_LT_PATTERN = /^\s*#(if|define).*</;
+  var JAVA_PARAM_ANNOT_PATTERN = /(@[^\s]+)\(([^)]+)\)/g;
+  var GLOBAL_LT_PATTERN = /</g;
+
   Polymer({
     is: 'gr-syntax-layer',
 
@@ -301,6 +305,7 @@
       var result;
 
       if (this._baseLanguage && baseLine !== undefined) {
+        baseLine = this._workaround(this._baseLanguage, baseLine);
         result = this._hljs.highlight(this._baseLanguage, baseLine, true,
             state.baseContext);
         this.push('_baseRanges', this._rangesFromString(result.value));
@@ -308,6 +313,7 @@
       }
 
       if (this._revisionLanguage && revisionLine !== undefined) {
+        revisionLine = this._workaround(this._revisionLanguage, revisionLine);
         result = this._hljs.highlight(this._revisionLanguage, revisionLine,
             true, state.revisionContext);
         this.push('_revisionRanges', this._rangesFromString(result.value));
@@ -316,6 +322,50 @@
     },
 
     /**
+     * Ad hoc fixes for HLJS parsing bugs. Rewrite lines of code in constrained
+     * cases before sending them into HLJS so that they parse correctly.
+     *
+     * Important notes:
+     * * These tests should be as constrained as possible to avoid interfering
+     *   with code it shouldn't AND to avoid executing regexes as much as
+     *   possible.
+     * * These tests should document the issue clearly enough that the test can
+     *   be condidently removed when the issue is solved in HLJS.
+     * * These tests should rewrite the line of code to have the same number of
+     *   characters. This method rewrites the string that gets parsed, but NOT
+     *   the string that gets displayed and highlighted. Thus, the positions
+     *   must be consistent.
+     *
+     * @param {!string} language The name of the HLJS language plugin in use.
+     * @param {!string} line The line of code to potentially rewrite.
+     * @return {string} A potentially-rewritten line of code.
+     */
+    _workaround: function(language, line) {
+      /**
+       * Prevent confusing < and << operators for the start of a meta string by
+       * converting them to a different operator.
+       * {@see Issue 4864}
+       * {@see https://github.com/isagalaev/highlight.js/issues/1341}
+       */
+      if (language === 'cpp' && CPP_DIRECTIVE_WITH_LT_PATTERN.test(line)) {
+        return line.replace(GLOBAL_LT_PATTERN, '|');
+      }
+
+      /**
+       * Prevent confusing the closing paren of a parameterized Java annotation
+       * being applied to a formal argument as the closing paren of the argument
+       * list. Rewrite the parens as spaces.
+       * {@see Issue 4776}
+       * {@see https://github.com/isagalaev/highlight.js/issues/1324}
+       */
+      if (language === 'java' && JAVA_PARAM_ANNOT_PATTERN.test(line)) {
+        return line.replace(JAVA_PARAM_ANNOT_PATTERN, '$1 $2 ');
+      }
+
+      return line;
+    },
+
+    /**
      * Tells whether the state has exhausted its current section.
      * @param {!Object} state
      * @return {boolean}
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
index aa37f1a..01e9325 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
@@ -404,5 +404,41 @@
       state = {sectionIndex: 3, lineIndex: 4};
       assert.isTrue(element._isSectionDone(state));
     });
+
+    test('workaround CPP LT directive', function() {
+      // Does nothing to regular line.
+      var line = 'int main(int argc, char** argv) { return 0; }';
+      assert.equal(element._workaround('cpp', line), line);
+
+      // Does nothing to include directive.
+      line = '#include <stdio>';
+      assert.equal(element._workaround('cpp', line), line);
+
+      // Converts left-shift operator in #define.
+      line = '#define GiB (1ull << 30)';
+      var expected = '#define GiB (1ull || 30)';
+      assert.equal(element._workaround('cpp', line), expected);
+
+      // Converts less-than operator in #if.
+      line = '  #if __GNUC__ < 4 || (__GNUC__ == 4 && __GNUC_MINOR__ < 1)';
+      expected = '  #if __GNUC__ | 4 || (__GNUC__ == 4 && __GNUC_MINOR__ | 1)';
+      assert.equal(element._workaround('cpp', line), expected);
+    });
+
+    test('workaround Java param-annotation', function() {
+      // Does nothing to regular line.
+      var line = 'public static void foo(int bar) { }';
+      assert.equal(element._workaround('java', line), line);
+
+      // Does nothing to regular annotation.
+      var line = 'public static void foo(@Nullable int bar) { }';
+      assert.equal(element._workaround('java', line), line);
+
+      // Converts parameterized annotation.
+      line = 'public static void foo(@SuppressWarnings("unused") int bar) { }';
+      var expected = 'public static void foo(@SuppressWarnings "unused" ' +
+          ' int bar) { }';
+      assert.equal(element._workaround('java', line), expected);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-theme-default.html b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-theme-default.html
index e2abc52..f796475 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-theme-default.html
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-theme-default.html
@@ -59,7 +59,6 @@
       }
       .gr-syntax-comment {
         color: #af72a9;
-        font-style: italic;
       }
       .gr-syntax-meta {
         color: #0091AD;
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index b758ff0..72f5254 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -62,6 +62,10 @@
       Gerrit.KeyboardShortcutBehavior,
     ],
 
+    keyBindings: {
+      '?': '_showKeyboardShortcuts',
+    },
+
     attached: function() {
       this.$.restAPI.getAccount().then(function(account) {
         this._account = account;
@@ -194,12 +198,9 @@
       }
     },
 
-    _handleKey: function(e) {
+    _showKeyboardShortcuts: function(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-      if (e.keyCode === 191 && e.shiftKey) {  // '/' or '?' with shift key.
-        this.$.keyboardShortcuts.open();
-      }
+      this.$.keyboardShortcuts.open();
     },
 
     _handleKeyboardShortcutDialogClose: function() {
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
index 0eace7d..e603e8c 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
@@ -32,6 +32,9 @@
       tbody tr:last-of-type td .move-down-button {
         display: none;
       }
+      td.urlCell {
+        word-break: break-word;
+      }
       .newTitleInput {
         width: 10em;
       }
@@ -52,7 +55,7 @@
           <template is="dom-repeat" items="[[menuItems]]">
             <tr>
               <td>[[item.name]]</td>
-              <td>[[item.url]]</td>
+              <td class="urlCell">[[item.url]]</td>
               <td>
                 <gr-button
                     data-index="[[index]]"
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
index bc9630e..ab65a51 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
@@ -263,6 +263,16 @@
             </span>
           </section>
           <section>
+            <span class="title">Show Trailing Whitespace</span>
+            <span class="value">
+              <input
+                  id="showTrailingWhitespace"
+                  type="checkbox"
+                  checked$="[[_diffPrefs.show_whitespace_errors]]"
+                  on-change="_handleShowTrailingWhitespaceChanged">
+            </span>
+          </section>
+          <section>
             <span class="title">Syntax Highlighting</span>
             <span class="value">
               <input
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
index 566b623..34ef958 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
@@ -214,6 +214,11 @@
       this.set('_diffPrefs.show_tabs', this.$.showTabs.checked);
     },
 
+    _handleShowTrailingWhitespaceChanged: function() {
+      this.set('_diffPrefs.show_whitespace_errors',
+          this.$.showTrailingWhitespace.checked);
+    },
+
     _handleSyntaxHighlightingChanged: function() {
       this.set('_diffPrefs.syntax_highlighting',
           this.$.syntaxHighlighting.checked);
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
index cb9d2eb..b9b049a 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
@@ -198,6 +198,8 @@
           .firstElementChild.bindValue, diffPreferences.font_size);
       assert.equal(valueOf('Show Tabs', 'diffPreferences')
           .firstElementChild.checked, diffPreferences.show_tabs);
+      assert.equal(valueOf('Show Trailing Whitespace', 'diffPreferences')
+          .firstElementChild.checked, diffPreferences.show_whitespace_errors);
       assert.equal(valueOf('Fit to Screen', 'diffPreferences')
           .firstElementChild.checked, diffPreferences.line_wrapping);
 
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html
index 28de9d4..96d1414 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html
@@ -109,6 +109,7 @@
           <span class="value">
             <iron-autogrow-textarea
                 id="newKey"
+                autocomplete="on"
                 bind-value="{{_newKey}}"
                 placeholder="New SSH Key"></iron-autogrow-textarea>
           </span>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
index dc1de17..496de03 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
@@ -53,6 +53,15 @@
         padding: 0;
         text-decoration: none;
       }
+      :host:focus {
+        border-color: transparent;
+        box-shadow: none;
+        outline: none;
+      }
+      :host:focus .container,
+      :host:focus gr-button {
+        background: #ccc;
+      }
       .transparentBackground,
       gr-button.transparentBackground {
         background-color: transparent;
@@ -61,6 +70,7 @@
     <div class$="container [[_getBackgroundClass(transparentBackground)]]">
       <gr-account-link account="[[account]]"></gr-account-link>
       <gr-button
+          id="remove"
           hidden$="[[!removable]]" hidden
           class$="remove [[_getBackgroundClass(transparentBackground)]]"
           on-tap="_handleRemoveTap">×</gr-button>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
index e33e1fc..33fc50e 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
@@ -18,6 +18,19 @@
   Polymer({
     is: 'gr-account-chip',
 
+    /**
+     * Fired to indicate a key was pressed while this chip was focused.
+     *
+     * @event account-chip-keydown
+     */
+
+    /**
+     * Fired to indicate this chip should be removed, i.e. when the x button is
+     * clicked or when the remove function is called.
+     *
+     * @event remove
+     */
+
     properties: {
       account: Object,
       removable: {
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
index 9bbcea5..1d2beab 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
@@ -42,7 +42,7 @@
       <span class="text">
         <span>[[account.name]]</span>
         <span hidden$="[[!_computeShowEmail(showEmail, account)]]">
-          ([[account.email]])
+          [[_computeEmailStr(account)]]
         </span>
       </span>
     </span>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
index 40b7cf1..ffbb55b 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
@@ -44,5 +44,12 @@
     _computeShowEmail: function(showEmail, account) {
       return !!(showEmail && account && account.email);
     },
+
+    _computeEmailStr: function(account) {
+      if (account.name) {
+        return '(' + account.email + ')';
+      }
+      return account.email;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
index f3d8861..dfff56d 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
@@ -71,6 +71,10 @@
 
       assert.equal(element._computeShowEmail(
           false, undefined), false);
+
+      assert.equal(
+          element._computeEmailStr({name: 'test', email: 'test'}), '(test)');
+      assert.equal(element._computeEmailStr({email: 'test'}, ''), 'test');
     });
 
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
index 864114f..d7017ca 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
@@ -14,6 +14,7 @@
 limitations under the License.
 -->
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
 
@@ -72,6 +73,7 @@
         id="cursor"
         index="{{_index}}"
         cursor-target-class="selected"
+        scroll-behavior="keep-visible"
         stops="[[_getSuggestionElems(_suggestions)]]"></gr-cursor-manager>
   </template>
   <script src="gr-autocomplete.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
index 1c9e010..863b85a 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
@@ -140,6 +140,10 @@
       this.$.input.focus();
     },
 
+    selectAll: function() {
+      this.$.input.setSelectionRange(0, this.$.input.value.length);
+    },
+
     clear: function() {
       this.text = '';
     },
@@ -195,6 +199,7 @@
     },
 
     _handleInputKeydown: function(e) {
+      this._focused = true;
       switch (e.keyCode) {
         case 38: // Up
           e.preventDefault();
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
index 03fa8e43..394b2c6 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
@@ -94,7 +94,7 @@
         var cancelHandler = sinon.spy();
         element.addEventListener('cancel', cancelHandler);
 
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 27); // Esc
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
         assert.isTrue(cancelHandler.called);
         assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
 
@@ -128,19 +128,22 @@
 
         assert.equal(element.$.cursor.index, 0);
 
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 40); // Down
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
+            'down');
 
         assert.equal(element.$.cursor.index, 1);
 
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 40); // Down
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
+            'down');
 
         assert.equal(element.$.cursor.index, 2);
 
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 38); // Up
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 38, null, 'up');
 
         assert.equal(element.$.cursor.index, 1);
 
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+            'enter');
 
         assert.equal(element.value, 1);
         assert.isTrue(commitHandler.called);
@@ -163,7 +166,8 @@
         var commitHandler = sinon.spy();
         element.addEventListener('commit', commitHandler);
 
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+            'enter');
 
         assert.isTrue(commitHandler.called);
         assert.equal(element.text, 'suggestion');
@@ -184,7 +188,8 @@
         var commitHandler = sinon.spy();
         element.addEventListener('commit', commitHandler);
 
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+            'enter');
 
         assert.isTrue(commitHandler.called);
         assert.equal(element.text, '');
@@ -234,7 +239,8 @@
         var commitHandler = sinon.spy();
         element.addEventListener('commit', commitHandler);
 
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+            'enter');
 
         assert.isTrue(commitHandler.called);
         assert.equal(element.text, 'blah 0');
@@ -245,10 +251,10 @@
     test('tab key completes only when suggestions exist', function() {
       var commitStub = sinon.stub(element, '_commit');
       element._suggestions = [];
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9); // tab
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
       assert.isFalse(commitStub.called);
       element._suggestions = ['tunnel snakes rule!'];
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9); // tab
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
       assert.isTrue(commitStub.called);
       commitStub.restore();
     });
@@ -258,11 +264,11 @@
       element.addEventListener('commit', commitHandler);
       element._suggestions = ['tunnel snakes rule!'];
       element.tabCompleteWithoutCommit = true;
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9); // tab
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
       assert.isFalse(commitHandler.called);
       element.tabCompleteWithoutCommit = false;
       element._suggestions = ['tunnel snakes rule!'];
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9); // tab
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
       assert.isTrue(commitHandler.called);
     });
 
@@ -301,7 +307,7 @@
     test('input-keydown event fired', function() {
       var listener = sinon.spy();
       element.addEventListener('input-keydown', listener);
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9); // tab
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
       flushAsynchronousOperations();
       assert.isTrue(listener.called);
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
index 164bb2d..8ca0e72 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
@@ -75,7 +75,6 @@
       }
       :host([disabled]) {
         cursor: default;
-        pointer-events: none;
       }
       :host([loading]),
       :host([loading][disabled]) {
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
index e109896..7e91b0e 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
@@ -29,6 +29,11 @@
       },
     },
 
+    listeners: {
+      'tap': '_handleAction',
+      'click': '_handleAction',
+    },
+
     behaviors: [
       Gerrit.KeyboardShortcutBehavior,
       Gerrit.TooltipBehavior,
@@ -39,6 +44,17 @@
       tabindex: '0',
     },
 
+    keyBindings: {
+      'space enter': '_handleCommitKey',
+    },
+
+    _handleAction: function(e) {
+      if (this.disabled) {
+        e.preventDefault();
+        e.stopImmediatePropagation();
+      }
+    },
+
     _disabledChanged: function(disabled) {
       if (disabled) {
         this._enabledTabindex = this.getAttribute('tabindex');
@@ -46,13 +62,9 @@
       this.setAttribute('tabindex', disabled ? '-1' : this._enabledTabindex);
     },
 
-    _handleKey: function(e) {
-      switch (e.keyCode) {
-        case 32:  // 'spacebar'
-        case 13:  // 'enter'
-          e.preventDefault();
-          this.click();
-      }
+    _handleCommitKey: function(e) {
+      e.preventDefault();
+      this.click();
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
new file mode 100644
index 0000000..70cf636
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
@@ -0,0 +1,93 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-button</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-button.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-button></gr-button>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-select tests', function() {
+    var element;
+    var sandbox;
+
+    var addSpyOn = function(eventName) {
+      var spy = sandbox.spy();
+      element.addEventListener(eventName, spy);
+      return spy;
+    };
+
+    setup(function() {
+      element = fixture('basic');
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    ['tap', 'click'].forEach(function(eventName) {
+      test('dispatches ' + eventName + ' event', function() {
+        var spy = addSpyOn(eventName);
+        MockInteractions.tap(element);
+        assert.isTrue(spy.calledOnce);
+      });
+    });
+
+    // Keycodes: 32 for Space, 13 for Enter.
+    [32, 13].forEach(function(key) {
+      test('dispatches tap event on keycode ' + key, function() {
+        var tapSpy = sandbox.spy();
+        element.addEventListener('tap', tapSpy);
+        MockInteractions.pressAndReleaseKeyOn(element, key);
+        assert.isTrue(tapSpy.calledOnce);
+      })});
+
+    suite('disabled', function() {
+      setup(function() {
+        element.disabled = true;
+      });
+
+      ['tap', 'click'].forEach(function(eventName) {
+        test('stops ' + eventName + ' event', function() {
+          var spy = addSpyOn(eventName);
+          MockInteractions.tap(element);
+          assert.isFalse(spy.called);
+        });
+      });
+
+      // Keycodes: 32 for Space, 13 for Enter.
+      [32, 13].forEach(function(key) {
+        test('stops tap event on keycode ' + key, function() {
+          var tapSpy = sandbox.spy();
+          element.addEventListener('tap', tapSpy);
+          MockInteractions.pressAndReleaseKeyOn(element, key);
+          assert.isFalse(tapSpy.called);
+        })});
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
index 7b3bc23..81f5186 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
@@ -59,7 +59,7 @@
        * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
        * the viewport.
        */
-      scroll: {
+      scrollBehavior: {
         type: String,
         value: ScrollBehavior.NEVER,
       },
@@ -108,6 +108,10 @@
       }
     },
 
+    setCursorAtIndex: function(index) {
+      this.setCursor(this.stops[index]);
+    },
+
     /**
      * Move the cursor forward or backward by delta. Noop if moving past either
      * end of the stop list.
@@ -194,7 +198,9 @@
     },
 
     _scrollToTarget: function() {
-      if (!this.target || this.scroll === ScrollBehavior.NEVER) { return; }
+      if (!this.target || this.scrollBehavior === ScrollBehavior.NEVER) {
+        return;
+      }
 
       // Calculate where the element is relative to the window.
       var top = this.target.offsetTop;
@@ -204,7 +210,7 @@
         top += offsetParent.offsetTop;
       }
 
-      if (this.scroll === ScrollBehavior.KEEP_VISIBLE &&
+      if (this.scrollBehavior === ScrollBehavior.KEEP_VISIBLE &&
           top > window.pageYOffset &&
           top < window.pageYOffset + window.innerHeight) { return; }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
new file mode 100644
index 0000000..00d50e9
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
@@ -0,0 +1,112 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-dropdown">
+  <template>
+    <style>
+      :host {
+        display: inline-block;
+      }
+      .dropdown-trigger {
+        text-decoration: none;
+      }
+      .dropdown-content {
+        background-color: #fff;
+        box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
+      }
+      button {
+        background: none;
+        border: none;
+        font: inherit;
+        padding: .3em 0;
+      }
+      gr-avatar {
+        height: 2em;
+        width: 2em;
+        vertical-align: middle;
+      }
+      ul {
+        list-style: none;
+      }
+      ul .accountName {
+        font-weight: bold;
+      }
+      li .accountInfo,
+      li a {
+        display: block;
+        padding: .85em 1em;
+      }
+      li a:link,
+      li a:visited {
+        color: #00e;
+        text-decoration: none;
+      }
+      li a:hover {
+        background-color: #6B82D6;
+        color: #fff;
+      }
+      .topContent {
+        display: block;
+        padding: .85em 1em;
+      }
+      .bold-text {
+        font-weight: bold;
+      }
+    </style>
+    <gr-button link class="dropdown-trigger" id="trigger"
+        on-tap="_showDropdownTapHandler">
+      <content></content>
+    </gr-button>
+    <iron-dropdown id="dropdown"
+        vertical-align="top"
+        vertical-offset="25"
+        horizontal-align="[[horizontalAlign]]"
+        on-tap="_handleDropdownTap">
+      <div class="dropdown-content">
+        <ul>
+          <template is="dom-if" if="[[topContent]]">
+            <div class="topContent">
+              <template
+                  is="dom-repeat"
+                  items="[[topContent]]"
+                  as="item"
+                  initial-count="75">
+                <div class$="[[_getClassIfBold(item.bold)]] top-item">
+                  [[item.text]]
+                </div>
+              </template>
+            </div>
+          </template>
+          <template
+              is="dom-repeat"
+              items="[[items]]"
+              as="link"
+              initial-count="75">
+            <li><a href$="[[_computeRelativeURL(link.url)]]">[[link.name]]</a>
+            </li>
+          </template>
+        </ul>
+      </div>
+    </iron-dropdown>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-dropdown.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
new file mode 100644
index 0000000..d10d219
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
@@ -0,0 +1,57 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-dropdown',
+
+    properties: {
+      items: Array,
+      topContent: Object,
+      horizontalAlign: {
+        type: String,
+        value: 'left',
+      },
+      _hasAvatars: String,
+    },
+
+    attached: function() {
+      this.$.restAPI.getConfig().then(function(cfg) {
+        this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
+      }.bind(this));
+    },
+
+    _handleDropdownTap: function(e) {
+      this.$.dropdown.close();
+    },
+
+    _showDropdownTapHandler: function(e) {
+      this.$.dropdown.open();
+    },
+
+    _getClassIfBold: function(bold) {
+      return bold ? 'bold-text' : '';
+    },
+
+    _computeURLHelper: function(host, path) {
+      return '//' + host + path;
+    },
+
+    _computeRelativeURL: function(path) {
+      var host = window.location.host;
+      return this._computeURLHelper(host, path);
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
new file mode 100644
index 0000000..b2f2d21
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
@@ -0,0 +1,74 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-dropdown</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-dropdown.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-dropdown></gr-dropdown>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-dropdown tests', function() {
+    var element;
+
+    setup(function() {
+      stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
+      });
+      element = fixture('basic');
+    });
+
+    test('tap on trigger opens menu', function() {
+      assert.isFalse(element.$.dropdown.opened);
+      MockInteractions.tap(element.$.trigger);
+      assert.isTrue(element.$.dropdown.opened);
+    });
+
+    test('_computeRelativeURL', function() {
+      var path = '/test';
+      var host = 'http://www.testsite.com';
+      var computedPath = element._computeURLHelper(host, path);
+      assert.equal(computedPath, '//http://www.testsite.com/test');
+    });
+
+    test('_getClassIfBold', function() {
+      var bold = true;
+      assert.equal(element._getClassIfBold(bold), 'bold-text');
+
+      bold = false;
+      assert.equal(element._getClassIfBold(bold), '');
+    });
+
+    test('Top text exists and is bolded correctly', function() {
+      element.topContent = [{text: 'User', bold: true}, {text: 'email'}];
+      flushAsynchronousOperations();
+      var topItems = Polymer.dom(element.root).querySelectorAll('.top-item');
+      assert.equal(topItems.length, 2);
+      assert.isTrue(topItems[0].classList.contains('bold-text'));
+      assert.isFalse(topItems[1].classList.contains('bold-text'));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html
index 5b49dcc..bd87db3 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html
@@ -43,6 +43,7 @@
     </div>
     <div class="editor" hidden$="[[!editing]]">
       <iron-autogrow-textarea
+          autocomplete="on"
           bind-value="{{_newContent}}"
           disabled="[[disabled]]"></iron-autogrow-textarea>
       <div class="editButtons">
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html
new file mode 100644
index 0000000..11560f1
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html
@@ -0,0 +1,46 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../gr-linked-text/gr-linked-text.html">
+
+<dom-module id="gr-formatted-text">
+  <template>
+    <style>
+      :host {
+        display: block;
+        font-family: var(--font-family);
+      }
+      p,
+      ul,
+      blockquote,
+      gr-linked-text.pre {
+        margin: 0 0 1.4em 0;
+      }
+      blockquote {
+        border-left: 1px solid #aaa;
+        padding: 0 .7em;
+      }
+      li {
+        margin-left: 1.4em;
+      }
+      gr-linked-text.pre {
+        font-family: var(--monospace-font-family);
+      }
+    </style>
+    <div id="container"></div>
+  </template>
+  <script src="gr-formatted-text.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
new file mode 100644
index 0000000..1b129a7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
@@ -0,0 +1,266 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var QUOTE_MARKER_PATTERN = /\n\s?>\s/g;
+
+  Polymer({
+    is: 'gr-formatted-text',
+
+    properties: {
+      content: String,
+      config: Object,
+    },
+
+    observers: [
+      '_contentOrConfigChanged(content, config)',
+    ],
+
+    /**
+     * Get the plain text as it appears in the generated DOM.
+     *
+     * This differs from the `content` property in that it will not include
+     * formatting markers such as > characters to make quotes or * and - markers
+     * to make list items.
+     *
+     * @return {string}
+     */
+    getTextContent: function() {
+      return this._blocksToText(this._computeBlocks(this.content));
+    },
+
+    /**
+     * Given a source string, update the DOM inside #container.
+     */
+    _contentOrConfigChanged: function(content) {
+      var container = Polymer.dom(this.$.container);
+
+      // Remove existing content.
+      while (container.firstChild) {
+        container.removeChild(container.firstChild);
+      }
+
+      // Add new content.
+      this._computeNodes(this._computeBlocks(content)).forEach(function(node) {
+        container.appendChild(node);
+      });
+    },
+
+    /**
+     * Given a source string, parse into an array of block objects. Each block
+     * has a `type` property which takes any of the follwoing values.
+     * * 'paragraph'
+     * * 'quote' (Block quote.)
+     * * 'pre' (Pre-formatted text.)
+     * * 'list' (Unordered list.)
+     *
+     * For blocks of type 'paragraph' and 'pre' there is a `text` property that
+     * maps to a string of the block's content.
+     *
+     * For blocks of type 'list', there is an `items` property that maps to a
+     * list of strings representing the list items.
+     *
+     * For blocks of type 'quote', there is a `blocks` property that maps to a
+     * list of blocks contained in the quote.
+     *
+     * NOTE: Strings appearing in all block objects are NOT escaped.
+     *
+     * @param {string} content
+     * @return {!Array<!Object>}
+     */
+    _computeBlocks: function(content) {
+      if (!content) { return []; }
+
+      var result = [];
+      var split = content.split('\n\n');
+      var p;
+
+      for (var i = 0; i < split.length; i++) {
+        p = split[i];
+        if (!p.length) { continue; }
+
+        if (this._isQuote(p)) {
+          result.push(this._makeQuote(p));
+        } else if (this._isPreFormat(p)) {
+          result.push({type: 'pre', text: p});
+        } else if (this._isList(p)) {
+          this._makeList(p, result);
+        } else {
+          result.push({type: 'paragraph', text: p});
+        }
+      }
+      return result;
+    },
+
+    /**
+     * Take a block of comment text that contains a list and potentially
+     * a paragraph (but does not contain blank lines), generate appropriate
+     * block objects and append them to the output list.
+     *
+     * In simple cases, this will generate a single list block. For example, on
+     * the following input.
+     *
+     *    * Item one.
+     *    * Item two.
+     *    * item three.
+     *
+     * However, if the list starts with a paragraph, it will need to also
+     * generate that paragraph. Consider the following input.
+     *
+     *    A bit of text describing the context of the list:
+     *    * List item one.
+     *    * List item two.
+     *    * Et cetera.
+     *
+     * In this case, `_makeList` generates a paragraph block object
+     * containing the non-bullet-prefixed text, followed by a list block.
+     *
+     * @param {!string} p The block containing the list (as well as a
+     *   potential paragraph).
+     * @param {!Array<!Object>} out The list of blocks to append to.
+     */
+    _makeList: function(p, out) {
+      var block = null;
+      var inList = false;
+      var inParagraph = false;
+      var lines = p.split('\n');
+      var line;
+
+      for (var i = 0; i < lines.length; i++) {
+        line = lines[i];
+
+        if (line[0] === '-' || line[0] === '*') {
+          // The next line looks like a list item. If not building a list
+          // already, then create one. Remove the list item marker (* or -) from
+          // the line.
+          if (!inList) {
+            if (inParagraph) {
+              // Add the finished paragraph block to the result.
+              inParagraph = false;
+              out.push(block);
+            }
+            inList = true;
+            block = {type: 'list', items: []};
+          }
+          line = line.substring(1).trim();
+        } else if (!inList) {
+          // Otherwise, if a list has not yet been started, but the next line
+          // does not look like a list item, then add the line to a paragraph
+          // block. If a paragraph block has not yet been started, then create
+          // one.
+          if (!inParagraph) {
+            inParagraph = true;
+            block = {type: 'paragraph', text: ''};
+          } else {
+            block.text += ' ';
+          }
+          block.text += line;
+          continue;
+        }
+        block.items.push(line);
+      }
+      if (block != null) {
+        out.push(block);
+      }
+    },
+
+    _makeQuote: function(p) {
+      var quotedLines = p
+          .split('\n')
+          .map(function(l) { return l.replace(/^[ ]?>[ ]?/, ''); })
+          .join('\n');
+      return {
+        type: 'quote',
+        blocks: this._computeBlocks(quotedLines),
+      };
+    },
+
+    _isQuote: function(p) {
+      return p.indexOf('> ') === 0 || p.indexOf(' > ') === 0;
+    },
+
+    _isPreFormat: function(p) {
+      return p.indexOf('\n ') !== -1 || p.indexOf('\n\t') !== -1 ||
+          p.indexOf(' ') === 0 || p.indexOf('\t') === 0;
+    },
+
+    _isList: function(p) {
+      return p.indexOf('\n- ') !== -1 || p.indexOf('\n* ') !== -1 ||
+          p.indexOf('- ') === 0 || p.indexOf('* ') === 0;
+    },
+
+    _makeLinkedText: function(content, isPre) {
+      var text = document.createElement('gr-linked-text');
+      text.config = this.config;
+      text.content = content;
+      text.pre = true;
+      if (isPre) {
+        text.classList.add('pre');
+      }
+      return text;
+    },
+
+    /**
+     * Map an array of block objects to an array of DOM nodes.
+     * @param  {!Array<!Object>} blocks
+     * @return {!Array<!HTMLElement>}
+     */
+    _computeNodes: function(blocks) {
+      return blocks.map(function(block) {
+        if (block.type === 'paragraph') {
+          var p = document.createElement('p');
+          p.appendChild(this._makeLinkedText(block.text));
+          return p;
+        }
+
+        if (block.type === 'quote') {
+          var bq = document.createElement('blockquote');
+          this._computeNodes(block.blocks).forEach(function(node) {
+            bq.appendChild(node);
+          });
+          return bq;
+        }
+
+        if (block.type === 'pre') {
+          return this._makeLinkedText(block.text, true);
+        }
+
+        if (block.type === 'list') {
+          var ul = document.createElement('ul');
+          block.items.forEach(function(item) {
+            var li = document.createElement('li');
+            li.appendChild(this._makeLinkedText(item));
+            ul.appendChild(li);
+          }.bind(this));
+          return ul;
+        }
+      }.bind(this));
+    },
+
+    _blocksToText: function(blocks) {
+      return blocks.map(function(block) {
+        if (block.type === 'paragraph' || block.type === 'pre') {
+          return block.text;
+        }
+        if (block.type === 'quote') {
+          return this._blocksToText(block.blocks);
+        }
+        if (block.type === 'list') {
+          return block.items.join('\n');
+        }
+      }.bind(this)).join('\n\n');
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html
new file mode 100644
index 0000000..1477d43
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html
@@ -0,0 +1,358 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-editable-label</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="gr-formatted-text.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-formatted-text></gr-formatted-text>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-formatted-text tests', function() {
+    var element;
+
+    function assertBlock(result, index, type, text) {
+      assert.equal(result[index].type, type);
+      assert.equal(result[index].text, text);
+    }
+
+    function assertListBlock(result, resultIndex, itemIndex, text) {
+      assert.equal(result[resultIndex].type, 'list');
+      assert.equal(result[resultIndex].items[itemIndex], text);
+    }
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('parse null undefined and empty', function() {
+      assert.lengthOf(element._computeBlocks(null), 0);
+      assert.lengthOf(element._computeBlocks(undefined), 0);
+      assert.lengthOf(element._computeBlocks(''), 0);
+    });
+
+    test('parse simple', function() {
+      var comment = 'Para1';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 1);
+      assertBlock(result, 0, 'paragraph', comment);
+    });
+
+    test('parse multiline para', function() {
+      var comment = 'Para 1\nStill para 1';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 1);
+      assertBlock(result, 0, 'paragraph', comment);
+    });
+
+    test('parse para break', function() {
+      var comment = 'Para 1\n\nPara 2\n\nPara 3';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 3);
+      assertBlock(result, 0, 'paragraph', 'Para 1');
+      assertBlock(result, 1, 'paragraph', 'Para 2');
+      assertBlock(result, 2, 'paragraph', 'Para 3');
+    });
+
+    test('parse quote', function() {
+      var comment = '> Quote text';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 1);
+      assert.equal(result[0].type, 'quote');
+      assert.lengthOf(result[0].blocks, 1);
+      assertBlock(result[0].blocks, 0, 'paragraph', 'Quote text');
+    });
+
+    test('parse quote lead space', function() {
+      var comment = ' > Quote text';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 1);
+      assert.equal(result[0].type, 'quote');
+      assert.lengthOf(result[0].blocks, 1);
+      assertBlock(result[0].blocks, 0, 'paragraph', 'Quote text');
+    });
+
+    test('parse excludes empty', function() {
+      var comment = 'Para 1\n\n\n\nPara 2';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 2);
+      assertBlock(result, 0, 'paragraph', 'Para 1');
+      assertBlock(result, 1, 'paragraph', 'Para 2');
+    });
+
+    test('parse multiline quote', function() {
+      var comment = '> Quote line 1\n> Quote line 2\n > Quote line 3\n';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 1);
+      assert.equal(result[0].type, 'quote');
+      assert.lengthOf(result[0].blocks, 1);
+      assertBlock(result[0].blocks, 0, 'paragraph',
+          'Quote line 1\nQuote line 2\nQuote line 3\n');
+    });
+
+    test('parse pre', function() {
+      var comment = '    Four space indent.';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 1);
+      assertBlock(result, 0, 'pre', comment);
+    });
+
+    test('parse one space pre', function() {
+      var comment = ' One space indent.\n Another line.';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 1);
+      assertBlock(result, 0, 'pre', comment);
+    });
+
+    test('parse tab pre', function() {
+      var comment = '\tOne tab indent.\n\tAnother line.\n  Yet another!';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 1);
+      assertBlock(result, 0, 'pre', comment);
+    });
+
+    test('parse intermediate leading whitespace pre', function() {
+      var comment = 'No indent.\n\tNonzero indent.\nNo indent again.';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 1);
+      assertBlock(result, 0, 'pre', comment);
+    });
+
+    test('parse star list', function() {
+      var comment = '* Item 1\n* Item 2\n* Item 3';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 1);
+      assertListBlock(result, 0, 0, 'Item 1');
+      assertListBlock(result, 0, 1, 'Item 2');
+      assertListBlock(result, 0, 2, 'Item 3');
+    });
+
+    test('parse dash list', function() {
+      var comment = '- Item 1\n- Item 2\n- Item 3';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 1);
+      assertListBlock(result, 0, 0, 'Item 1');
+      assertListBlock(result, 0, 1, 'Item 2');
+      assertListBlock(result, 0, 2, 'Item 3');
+    });
+
+    test('parse mixed list', function() {
+      var comment = '- Item 1\n* Item 2\n- Item 3\n* Item 4';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 1);
+      assertListBlock(result, 0, 0, 'Item 1');
+      assertListBlock(result, 0, 1, 'Item 2');
+      assertListBlock(result, 0, 2, 'Item 3');
+      assertListBlock(result, 0, 3, 'Item 4');
+    });
+
+    test('parse mixed block types', function() {
+      var comment = 'Paragraph\nacross\na\nfew\nlines.' +
+          '\n\n' +
+          '> Quote\n> across\n> not many lines.' +
+          '\n\n' +
+          'Another paragraph' +
+          '\n\n' +
+          '* Series\n* of\n* list\n* items' +
+          '\n\n' +
+          'Yet another paragraph' +
+          '\n\n' +
+          '\tPreformatted text.' +
+          '\n\n' +
+          'Parting words.';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 7);
+      assertBlock(result, 0, 'paragraph', 'Paragraph\nacross\na\nfew\nlines.');
+
+      assert.equal(result[1].type, 'quote');
+      assert.lengthOf(result[1].blocks, 1);
+      assertBlock(result[1].blocks, 0, 'paragraph',
+          'Quote\nacross\nnot many lines.');
+
+      assertBlock(result, 2, 'paragraph', 'Another paragraph');
+      assertListBlock(result, 3, 0, 'Series');
+      assertListBlock(result, 3, 1, 'of');
+      assertListBlock(result, 3, 2, 'list');
+      assertListBlock(result, 3, 3, 'items');
+      assertBlock(result, 4, 'paragraph', 'Yet another paragraph');
+      assertBlock(result, 5, 'pre', '\tPreformatted text.');
+      assertBlock(result, 6, 'paragraph', 'Parting words.');
+    });
+
+    test('bullet list 1', function() {
+      var comment = 'A\n\n* line 1\n* 2nd line';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 2);
+      assertBlock(result, 0, 'paragraph', 'A');
+      assertListBlock(result, 1, 0, 'line 1');
+      assertListBlock(result, 1, 1, '2nd line');
+    });
+
+    test('bullet list 2', function() {
+      var comment = 'A\n\n* line 1\n* 2nd line\n\nB';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 3);
+      assertBlock(result, 0, 'paragraph', 'A');
+      assertListBlock(result, 1, 0, 'line 1');
+      assertListBlock(result, 1, 1, '2nd line');
+      assertBlock(result, 2, 'paragraph', 'B');
+    });
+
+    test('bullet list 3', function() {
+      var comment = '* line 1\n* 2nd line\n\nB';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 2);
+      assertListBlock(result, 0, 0, 'line 1');
+      assertListBlock(result, 0, 1, '2nd line');
+      assertBlock(result, 1, 'paragraph', 'B');
+    });
+
+    test('bullet list 4', function() {
+      var comment = 'To see this bug, you have to:\n' +
+          '* Be on IMAP or EAS (not on POP)\n' +
+          '* Be very unlucky\n';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 2);
+      assertBlock(result, 0, 'paragraph', 'To see this bug, you have to:');
+      assertListBlock(result, 1, 0, 'Be on IMAP or EAS (not on POP)');
+      assertListBlock(result, 1, 1, 'Be very unlucky');
+    });
+
+    test('bullet list 5', function() {
+      var comment = 'To see this bug,\n' +
+          'you have to:\n' +
+          '* Be on IMAP or EAS (not on POP)\n' +
+          '* Be very unlucky\n';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 2);
+      assertBlock(result, 0, 'paragraph', 'To see this bug, you have to:');
+      assertListBlock(result, 1, 0, 'Be on IMAP or EAS (not on POP)');
+      assertListBlock(result, 1, 1, 'Be very unlucky');
+    });
+
+    test('dash list 1', function() {
+      var comment = 'A\n\n- line 1\n- 2nd line';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 2);
+      assertBlock(result, 0, 'paragraph', 'A');
+      assertListBlock(result, 1, 0, 'line 1');
+      assertListBlock(result, 1, 1, '2nd line');
+    });
+
+    test('dash list 2', function() {
+      var comment = 'A\n\n- line 1\n- 2nd line\n\nB';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 3);
+      assertBlock(result, 0, 'paragraph', 'A');
+      assertListBlock(result, 1, 0, 'line 1');
+      assertListBlock(result, 1, 1, '2nd line');
+      assertBlock(result, 2, 'paragraph', 'B');
+    });
+
+    test('dash list 3', function() {
+      var comment = '- line 1\n- 2nd line\n\nB';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 2);
+      assertListBlock(result, 0, 0, 'line 1');
+      assertListBlock(result, 0, 1, '2nd line');
+      assertBlock(result, 1, 'paragraph', 'B');
+    });
+
+    test('pre format 1', function() {
+      var comment = 'A\n\n  This is pre\n  formatted';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 2);
+      assertBlock(result, 0, 'paragraph', 'A');
+      assertBlock(result, 1, 'pre', '  This is pre\n  formatted');
+    });
+
+    test('pre format 2', function() {
+      var comment = 'A\n\n  This is pre\n  formatted\n\nbut this is not';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 3);
+      assertBlock(result, 0, 'paragraph', 'A');
+      assertBlock(result, 1, 'pre', '  This is pre\n  formatted');
+      assertBlock(result, 2, 'paragraph', 'but this is not');
+    });
+
+    test('pre format 3', function() {
+      var comment = 'A\n\n  Q\n    <R>\n  S\n\nB';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 3);
+      assertBlock(result, 0, 'paragraph', 'A');
+      assertBlock(result, 1, 'pre', '  Q\n    <R>\n  S');
+      assertBlock(result, 2, 'paragraph', 'B');
+    });
+
+    test('pre format 4', function() {
+      var comment = '  Q\n    <R>\n  S\n\nB';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 2);
+      assertBlock(result, 0, 'pre', '  Q\n    <R>\n  S');
+      assertBlock(result, 1, 'paragraph', 'B');
+    });
+
+    test('quote 1', function() {
+      var comment = '> I\'m happy\n > with quotes!\n\nSee above.';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 2);
+      assert.equal(result[0].type, 'quote');
+      assert.lengthOf(result[0].blocks, 1);
+      assertBlock(result[0].blocks, 0, 'paragraph', 'I\'m happy\nwith quotes!');
+      assertBlock(result, 1, 'paragraph', 'See above.');
+    });
+
+    test('quote 2', function() {
+      var comment = 'See this said:\n\n > a quoted\n > string block\n\nOK?';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 3);
+      assertBlock(result, 0, 'paragraph', 'See this said:');
+      assert.equal(result[1].type, 'quote');
+      assert.lengthOf(result[1].blocks, 1);
+      assertBlock(result[1].blocks, 0, 'paragraph', 'a quoted\nstring block');
+      assertBlock(result, 2, 'paragraph', 'OK?');
+    });
+
+    test('nested quotes', function() {
+      var comment = ' > > prior\n > \n > next\n';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 1);
+      assert.equal(result[0].type, 'quote');
+      assert.lengthOf(result[0].blocks, 2);
+      assert.equal(result[0].blocks[0].type, 'quote');
+      assert.lengthOf(result[0].blocks[0].blocks, 1);
+      assertBlock(result[0].blocks[0].blocks, 0, 'paragraph', 'prior');
+      assertBlock(result[0].blocks, 1, 'paragraph', 'next\n');
+    });
+
+    test('getTextContent', function() {
+      var comment = 'Paragraph\n\n  pre\n\n* List\n* Of\n* Items\n\n> Quote';
+      element.content = comment;
+      var result = element.getTextContent();
+      var expected = 'Paragraph\n\n  pre\n\nList\nOf\nItems\n\nQuote';
+      assert.equal(result, expected);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
index ad8c135..61cb003 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
@@ -44,6 +44,10 @@
     return this._name;
   };
 
+  Plugin.prototype.getServerInfo = function() {
+    return document.createElement('gr-rest-api-interface').getConfig();
+  };
+
   Plugin.prototype.on = function(eventName, callback) {
     Plugin._sharedAPIElement.addEventCallback(eventName, callback);
   };
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html
new file mode 100644
index 0000000..a3d2769
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html
@@ -0,0 +1,66 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../gr-button/gr-button.html">
+<dom-module id="gr-linked-chip">
+  <template>
+    <style>
+      :host {
+        display: block;
+        overflow: hidden;
+      }
+      .container {
+        align-items: center;
+        background: #eee;
+        border-radius: .75em;
+        display: inline-flex;
+        padding: 0 .5em;
+      }
+      gr-button.remove,
+      gr-button.remove:hover,
+      gr-button.remove:focus {
+        border-color: transparent;
+        color: #333;
+      }
+      gr-button.remove {
+        background: #eee;
+        color: #666;
+        font-size: 1.7em;
+        font-weight: normal;
+        height: .6em;
+        line-height: .6em;
+        margin-left: .15em;
+        padding: 0;
+        text-decoration: none;
+      }
+      .transparentBackground,
+      gr-button.transparentBackground {
+        background-color: transparent;
+      }
+    </style>
+    <div class$="container [[_getBackgroundClass(transparentBackground)]]">
+      <a href$="[[href]]">[[text]]</a>
+      <gr-button
+          id="remove"
+          hidden$="[[!removable]]"
+          hidden
+          class$="remove [[_getBackgroundClass(transparentBackground)]]"
+          on-tap="_handleRemoveTap">×</gr-button>
+    </div>
+  </template>
+  <script src="gr-linked-chip.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
new file mode 100644
index 0000000..c6a5e4e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
@@ -0,0 +1,42 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-linked-chip',
+
+    properties: {
+      href: String,
+      removable: {
+        type: Boolean,
+        value: false,
+      },
+      text: String,
+      transparentBackground: {
+        type: Boolean,
+        value: false,
+      },
+    },
+
+    _getBackgroundClass: function(transparent) {
+      return transparent ? 'transparentBackground' : '';
+    },
+
+    _handleRemoveTap: function(e) {
+      e.preventDefault();
+      this.fire('remove');
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
new file mode 100644
index 0000000..5e2cac5
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
@@ -0,0 +1,56 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-linked-chip</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../bower_components/iron-test-helpers/mock-interactions.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-linked-chip.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-linked-chip></gr-linked-chip>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-linked-chip tests', function() {
+    var element;
+    var sandbox;
+
+    setup(function() {
+      element = fixture('basic');
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    test('remove fired', function() {
+      var spy = sandbox.spy();
+      element.addEventListener('remove', spy);
+      flushAsynchronousOperations();
+      MockInteractions.tap(element.$.remove);
+      assert.isTrue(spy.called);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
index 7feaa51..90b09ab 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
@@ -175,5 +175,28 @@
       assert.equal(element.$.output.innerHTML, 'foo:baz');
     });
 
+    test('overlapping links', function() {
+      element.config = {
+        b1: {
+          match: '(B:\\s*)(\\d+)',
+          html: '$1<a href="ftp://foo/$2">$2</a>',
+        },
+        b2: {
+          match: '(B:\\s*\\d+\\s*,\\s*)(\\d+)',
+          html: '$1<a href="ftp://foo/$2">$2</a>',
+        },
+      };
+      element.content = '- B: 123, 45';
+      var links = Polymer.dom(element.root).querySelectorAll('a');
+
+      assert.equal(links.length, 2);
+      assert.equal(element.$$('span').textContent, '- B: 123, 45');
+
+      assert.equal(links[0].href, 'ftp://foo/123');
+      assert.equal(links[0].textContent, '123');
+
+      assert.equal(links[1].href, 'ftp://foo/45');
+      assert.equal(links[1].textContent, '45');
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
index 303a9cc..b28097a 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
@@ -165,12 +165,27 @@
       var result = match[0].replace(pattern,
           patterns[p].html || patterns[p].link);
 
+      // Skip portion of replacement string that is equal to original.
+      for (var i = 0; i < result.length; i++) {
+        if (result[i] !== match[0][i]) {
+          break;
+        }
+      }
+      result = result.slice(i);
+
       if (patterns[p].html) {
         this.addHTML(
-            result, susbtrIndex + match.index, match[0].length, outputArray);
+          result,
+          susbtrIndex + match.index + i,
+          match[0].length - i,
+          outputArray);
       } else if (patterns[p].link) {
-        this.addLink(match[0], result,
-            susbtrIndex + match.index, match[0].length, outputArray);
+        this.addLink(
+          match[0],
+          result,
+          susbtrIndex + match.index + i,
+          match[0].length - i,
+          outputArray);
       } else {
         throw Error('linkconfig entry ' + p +
             ' doesn’t contain a link or html attribute.');
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
index 817d8c5..9aa80b5 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
@@ -16,7 +16,6 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-overlay-behavior/iron-overlay-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
 
 <dom-module id="gr-overlay">
   <template>
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
index f4a389a..9f271ed 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
@@ -24,28 +24,13 @@
       Polymer.IronOverlayBehavior,
     ],
 
-    detached: function() {
-      Gerrit.KeyboardShortcutBehavior.enable(this._id());
-    },
-
     open: function() {
       return new Promise(function(resolve) {
-        Gerrit.KeyboardShortcutBehavior.disable(this._id());
         Polymer.IronOverlayBehaviorImpl.open.apply(this, arguments);
         this._awaitOpen(resolve);
       }.bind(this));
     },
 
-    close: function() {
-      Gerrit.KeyboardShortcutBehavior.enable(this._id());
-      Polymer.IronOverlayBehaviorImpl.close.apply(this, arguments);
-    },
-
-    cancel: function() {
-      Gerrit.KeyboardShortcutBehavior.enable(this._id());
-      Polymer.IronOverlayBehaviorImpl.cancel.apply(this, arguments);
-    },
-
     /**
      * Override the focus stops that iron-overlay-behavior tries to find.
      */
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
index f34ffcf..b5c9f0c 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
@@ -22,4 +22,3 @@
 <dom-module id="gr-rest-api-interface">
   <script src="gr-rest-api-interface.js"></script>
 </dom-module>
-
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index 672afa8..8a66533 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -68,7 +68,7 @@
     REVIEWER_UPDATES: 19,
 
     // Set the submittable boolean.
-    SUBMITTABLE: 20
+    SUBMITTABLE: 20,
   };
 
   Polymer({
@@ -212,6 +212,8 @@
     },
 
     saveDiffPreferences: function(prefs, opt_errFn, opt_ctx) {
+      // Invalidate the cache.
+      this._cache['/accounts/self/preferences.diff'] = undefined;
       return this.send('PUT', '/accounts/self/preferences.diff', prefs,
           opt_errFn, opt_ctx);
     },
@@ -372,8 +374,9 @@
         O: options,
         q: [
           'is:open owner:self',
-          'is:open reviewer:self -owner:self',
-          'is:closed (owner:self OR reviewer:self) -age:4w limit:10',
+          'is:open ((reviewer:self -owner:self -star:ignore) OR assignee:self)',
+          'is:closed (owner:self OR reviewer:self OR assignee:self) -age:4w ' +
+            'limit:10',
         ],
       };
       return this.fetchJSON('/changes/', null, null, params);
@@ -387,11 +390,14 @@
       var options = this._listChangesOptionsToHex(
           ListChangesOption.ALL_REVISIONS,
           ListChangesOption.CHANGE_ACTIONS,
+          ListChangesOption.CURRENT_ACTIONS,
+          ListChangesOption.CURRENT_COMMIT,
           ListChangesOption.DOWNLOAD_COMMANDS,
-          ListChangesOption.SUBMITTABLE
+          ListChangesOption.SUBMITTABLE,
+          ListChangesOption.WEB_LINKS
       );
-      return this._getChangeDetail(changeNum, options, opt_errFn,
-          opt_cancelCondition);
+      return this._getChangeDetail(
+          changeNum, options, opt_errFn, opt_cancelCondition);
     },
 
     getDiffChangeDetail: function(changeNum, opt_errFn, opt_cancelCondition) {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
index 392c320..d07968f 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
@@ -330,5 +330,14 @@
       element.getChanges(1, null, 'n,z');
       assert.equal(stub.args[0][3].S, 0);
     });
+
+    test('saveDiffPreferences invalidates cache line', function() {
+      var cacheKey = '/accounts/self/preferences.diff';
+      var sendStub = sandbox.stub(element, 'send');
+      element._cache[cacheKey] = {tab_size: 4};
+      element.saveDiffPreferences({tab_size: 8});
+      assert.isTrue(sendStub.called);
+      assert.notOk(element._cache[cacheKey]);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/index.html b/polygerrit-ui/app/index.html
index a5cddad..2649c21 100644
--- a/polygerrit-ui/app/index.html
+++ b/polygerrit-ui/app/index.html
@@ -20,6 +20,12 @@
 <meta name="description" content="Gerrit Code Review">
 <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
 
+<!--
+SourceCodePro fonts are used in styles/fonts.css
+@see https://github.com/w3c/preload/issues/32 regarding crossorigin
+-->
+<link rel="preload" href="/fonts/SourceCodePro-Regular.woff2" as="font" type="font/woff2" crossorigin>
+<link rel="preload" href="/fonts/SourceCodePro-Regular.woff" as="font" type="font/woff" crossorigin>
 <link rel="stylesheet" href="/styles/fonts.css">
 <link rel="stylesheet" href="/styles/main.css">
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index a25656f..48ddaf2 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -27,6 +27,9 @@
 
   // Elements tests.
   [
+    'change-list/gr-change-list-item/gr-change-list-item_test.html',
+    'change-list/gr-change-list-view/gr-change-list-view_test.html',
+    'change-list/gr-change-list/gr-change-list_test.html',
     'change/gr-account-entry/gr-account-entry_test.html',
     'change/gr-account-list/gr-account-list_test.html',
     'change/gr-change-actions/gr-change-actions_test.html',
@@ -43,19 +46,14 @@
     'change/gr-related-changes-list/gr-related-changes-list_test.html',
     'change/gr-reply-dialog/gr-reply-dialog_test.html',
     'change/gr-reviewer-list/gr-reviewer-list_test.html',
-    'change-list/gr-change-list/gr-change-list_test.html',
-    'change-list/gr-change-list-item/gr-change-list-item_test.html',
-    'change-list/gr-change-list-view/gr-change-list-view_test.html',
     'core/gr-account-dropdown/gr-account-dropdown_test.html',
     'core/gr-error-manager/gr-error-manager_test.html',
     'core/gr-main-header/gr-main-header_test.html',
     'core/gr-reporting/gr-reporting_test.html',
     'core/gr-search-bar/gr-search-bar_test.html',
-    'diff/gr-diff/gr-diff-group_test.html',
-    'diff/gr-diff/gr-diff_test.html',
     'diff/gr-diff-builder/gr-diff-builder_test.html',
-    'diff/gr-diff-comment/gr-diff-comment_test.html',
     'diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html',
+    'diff/gr-diff-comment/gr-diff-comment_test.html',
     'diff/gr-diff-cursor/gr-diff-cursor_test.html',
     'diff/gr-diff-highlight/gr-annotation_test.html',
     'diff/gr-diff-highlight/gr-diff-highlight_test.html',
@@ -63,6 +61,8 @@
     'diff/gr-diff-processor/gr-diff-processor_test.html',
     'diff/gr-diff-selection/gr-diff-selection_test.html',
     'diff/gr-diff-view/gr-diff-view_test.html',
+    'diff/gr-diff/gr-diff-group_test.html',
+    'diff/gr-diff/gr-diff_test.html',
     'diff/gr-patch-range-select/gr-patch-range-select_test.html',
     'diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html',
     'diff/gr-selection-action-box/gr-selection-action-box_test.html',
@@ -83,15 +83,18 @@
     'shared/gr-alert/gr-alert_test.html',
     'shared/gr-autocomplete/gr-autocomplete_test.html',
     'shared/gr-avatar/gr-avatar_test.html',
+    'shared/gr-button/gr-button_test.html',
     'shared/gr-change-star/gr-change-star_test.html',
     'shared/gr-confirm-dialog/gr-confirm-dialog_test.html',
     'shared/gr-cursor-manager/gr-cursor-manager_test.html',
     'shared/gr-date-formatter/gr-date-formatter_test.html',
     'shared/gr-editable-content/gr-editable-content_test.html',
     'shared/gr-editable-label/gr-editable-label_test.html',
+    'shared/gr-formatted-text/gr-formatted-text_test.html',
     'shared/gr-js-api-interface/gr-change-actions-js-api_test.html',
     'shared/gr-js-api-interface/gr-change-reply-js-api_test.html',
     'shared/gr-js-api-interface/gr-js-api-interface_test.html',
+    'shared/gr-linked-chip/gr-linked-chip_test.html',
     'shared/gr-linked-text/gr-linked-text_test.html',
     'shared/gr-rest-api-interface/gr-rest-api-interface_test.html',
     'shared/gr-select/gr-select_test.html',
@@ -104,6 +107,7 @@
 
   // Behaviors tests.
   [
+    'gr-patch-set-behavior/gr-patch-set-behavior_test.html',
     'gr-path-list-behavior/gr-path-list-behavior_test.html',
   ].forEach(function(file) {
     file = behaviorsPath + file;
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index cb6d236..33f33b7 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -32,6 +32,7 @@
 	restHost = flag.String("host", "gerrit-review.googlesource.com", "Host to proxy requests to")
 	port     = flag.String("port", ":8081", "Port to serve HTTP requests on")
 	prod     = flag.Bool("prod", false, "Serve production assets")
+	scheme   = flag.String("scheme", "https", "URL scheme")
 )
 
 func main() {
@@ -57,7 +58,7 @@
 	req := &http.Request{
 		Method: "GET",
 		URL: &url.URL{
-			Scheme:   "https",
+			Scheme:   *scheme,
 			Host:     *restHost,
 			Opaque:   r.URL.EscapedPath(),
 			RawQuery: r.URL.RawQuery,
diff --git a/tools/bzl/classpath.bzl b/tools/bzl/classpath.bzl
new file mode 100644
index 0000000..b04c3ba
--- /dev/null
+++ b/tools/bzl/classpath.bzl
@@ -0,0 +1,22 @@
+
+def _classpath_collector(ctx):
+    all = set()
+    for d in ctx.attr.deps:
+        if hasattr(d, 'java'):
+            all += d.java.transitive_runtime_deps
+            all += d.java.compilation_info.runtime_classpath
+        elif hasattr(d, 'files'):
+            all += d.files
+
+    as_strs = [c.path for c in all]
+    ctx.file_action(output= ctx.outputs.runtime,
+                    content="\n".join(sorted(as_strs)))
+
+classpath_collector = rule(
+    implementation = _classpath_collector,
+    attrs = {
+        "deps": attr.label_list(),
+    },
+    outputs={
+        "runtime": "%{name}.runtime_classpath"
+    })
diff --git a/tools/bzl/gwt.bzl b/tools/bzl/gwt.bzl
index 7eff506..4866b23 100644
--- a/tools/bzl/gwt.bzl
+++ b/tools/bzl/gwt.bzl
@@ -48,6 +48,19 @@
   '-XdisableCastChecking',
 ]
 
+PLUGIN_DEPS_NEVERLINK = [
+  '//gerrit-plugin-api:lib-neverlink',
+]
+
+GWT_PLUGIN_DEPS_NEVERLINK = [
+  '//gerrit-plugin-gwtui:gwtui-api-lib-neverlink',
+  '//lib/gwt:user-neverlink',
+]
+
+GWT_PLUGIN_DEPS = [
+  '//gerrit-plugin-gwtui:gwtui-api-lib',
+]
+
 GWT_TRANSITIVE_DEPS = [
   '//lib/gwt:ant',
   '//lib/gwt:colt',
@@ -68,7 +81,7 @@
   '//gerrit-gwtexpui:CSS',
   '//lib:gwtjsonrpc',
   '//lib/gwt:dev',
-  '@jgit_src//file',
+  '@jgit//jar:src',
 ]
 
 USER_AGENT_XML = """<module rename-to='gerrit_ui'>
@@ -126,7 +139,7 @@
   )
 
 def _gwt_binary_impl(ctx):
-  module = MODULE
+  module = ctx.attr.module[0]
   output_zip = ctx.outputs.output
   output_dir = output_zip.path + '.gwt_output'
   deploy_dir = output_zip.path + '.gwt_deploy'
@@ -195,6 +208,7 @@
     "style": attr.string(default = "OBF"),
     "optimize": attr.string(default = "9"),
     "deps": attr.label_list(allow_files=jar_filetype),
+    "module": attr.string_list(default = [MODULE]),
     "module_deps": attr.label_list(allow_files=jar_filetype),
     "compiler_args": attr.string_list(),
     "jvm_args": attr.string_list(),
@@ -237,6 +251,7 @@
 
   gwt_binary(
     name = opt,
+    module = [MODULE],
     module_deps = [module_dep],
     deps = DEPS,
     compiler_args = args,
@@ -279,6 +294,7 @@
       user_agent = ua,
       style = 'PRETTY',
       optimize = "0",
+      module = [MODULE],
       module_deps = [':ui_module'],
       deps = DEPS,
       compiler_args = GWT_COMPILER_ARGS,
diff --git a/tools/bzl/js.bzl b/tools/bzl/js.bzl
index d603f8f..df68e62 100644
--- a/tools/bzl/js.bzl
+++ b/tools/bzl/js.bzl
@@ -1,5 +1,5 @@
 NPMJS = "NPMJS"
-GERRIT = "GERRIT"
+GERRIT = "GERRIT:"
 
 NPM_VERSIONS = {
   "bower":   '1.7.9',
@@ -311,13 +311,22 @@
     "cd %s" % destdir,
     hermetic_npm_binary,
   ])
+
+  # Node/NPM is not (yet) hermeticized, so we have to get the binary
+  # from the environment, and it may be under $HOME, so we can't run
+  # in the sandbox.
+  node_tweaks = dict(
+    use_default_shell_env = True,
+    execution_requirements = {"local": "1"},
+  )
   ctx.action(
     mnemonic = "Vulcanize",
     inputs = [ctx.file._run_npm, ctx.file.app,
               ctx.file._vulcanize_archive
     ] + list(zips) + ctx.files.srcs,
     outputs = [vulcanized],
-    command = cmd)
+    command = cmd,
+    **node_tweaks)
 
   hermetic_npm_command = "export PATH && " + " ".join([
     'python',
@@ -333,7 +342,9 @@
     inputs = [ctx.file._run_npm, ctx.file.app,
               ctx.file._crisper_archive, vulcanized],
     outputs = [ctx.outputs.js, ctx.outputs.html],
-    command = hermetic_npm_command)
+    command = hermetic_npm_command,
+    **node_tweaks)
+
 
 
 _vulcanize_rule = rule(
diff --git a/tools/bzl/maven_jar.bzl b/tools/bzl/maven_jar.bzl
new file mode 100644
index 0000000..17d1423
--- /dev/null
+++ b/tools/bzl/maven_jar.bzl
@@ -0,0 +1,131 @@
+GERRIT = 'GERRIT:'
+GERRIT_API = 'GERRIT_API:'
+MAVEN_CENTRAL = 'MAVEN_CENTRAL:'
+MAVEN_LOCAL = 'MAVEN_LOCAL:'
+
+def _maven_release(ctx, parts):
+  """induce jar and url name from maven coordinates."""
+  if len(parts) not in [3, 4]:
+    fail('%s:\nexpected id="groupId:artifactId:version[:classifier]"'
+         % ctx.attr.artifact)
+  if len(parts) == 4:
+    group, artifact, version, classifier = parts
+    file_version = version + '-' + classifier
+  else:
+    group, artifact, version = parts
+    file_version = version
+
+  jar = artifact.lower() + '-' + file_version
+  url = '/'.join([
+    ctx.attr.repository,
+    group.replace('.', '/'),
+    artifact,
+    version,
+    artifact + '-' + file_version])
+
+  return jar, url
+
+# Creates a struct containing the different parts of an artifact's FQN
+def _create_coordinates(fully_qualified_name):
+  parts = fully_qualified_name.split(":")
+  packaging = None
+  classifier = None
+
+  if len(parts) == 3:
+    group_id, artifact_id, version = parts
+  elif len(parts) == 4:
+    group_id, artifact_id, version, packaging = parts
+  elif len(parts) == 5:
+    group_id, artifact_id, version, packaging, classifier = parts
+  else:
+    fail("Invalid fully qualified name for artifact: %s" % fully_qualified_name)
+
+  return struct(
+      fully_qualified_name = fully_qualified_name,
+      group_id = group_id,
+      artifact_id = artifact_id,
+      packaging = packaging,
+      classifier = classifier,
+      version = version,
+  )
+
+def _generate_build_file(ctx, binjar, srcjar):
+  srcjar_attr = ""
+  if srcjar:
+    srcjar_attr = 'srcjar = "%s",' % srcjar
+  contents = """
+# DO NOT EDIT: automatically generated BUILD file for maven_archive rule {rule_name}
+package(default_visibility = ['//visibility:public'])
+java_import(
+    name = 'jar',
+    {srcjar_attr}
+    jars = ['{binjar}'],
+)
+\n""".format(srcjar_attr = srcjar_attr,
+              rule_name = ctx.name,
+              binjar = binjar)
+  if srcjar:
+    contents += """
+java_import(
+    name = 'src',
+    jars = ['{srcjar}'],
+)
+""".format(srcjar = srcjar)
+  ctx.file('%s/BUILD' % ctx.path("jar"), contents, False)
+
+def _maven_jar_impl(ctx):
+  """rule to download a Maven archive."""
+  coordinates = _create_coordinates(ctx.attr.artifact)
+
+  name = ctx.name
+  sha1 = ctx.attr.sha1
+
+  parts = ctx.attr.artifact.split(':')
+  # TODO(davido): Only releases for now, implement handling snapshots
+  jar, url = _maven_release(ctx, parts)
+
+  binjar = jar + '.jar'
+  binjar_path = ctx.path('/'.join(['jar', binjar]))
+  binurl = url + '.jar'
+
+  python = ctx.which("python")
+  script = ctx.path(ctx.attr._download_script)
+
+  args = [python, script, "-o", binjar_path, "-u", binurl, "-v", sha1]
+  if ctx.attr.unsign:
+    args.append('--unsign')
+  for x in ctx.attr.exclude:
+    args.extend(['-x', x])
+
+  out = ctx.execute(args)
+
+  if out.return_code:
+    fail("failed %s: %s" % (' '.join(args), out.stderr))
+
+  srcjar = None
+  if ctx.attr.src_sha1 or ctx.attr.attach_source:
+    srcjar = jar + '-src.jar'
+    srcurl = url + '-sources.jar'
+    srcjar_path = ctx.path('jar/' + srcjar)
+    args = [python, script, "-o", srcjar_path, "-u", srcurl]
+    if ctx.attr.src_sha1:
+      args.extend(['-v', ctx.attr.src_sha1])
+    out = ctx.execute(args)
+    if out.return_code:
+      fail("failed %s: %s" % (args, out.stderr))
+
+  _generate_build_file(ctx, binjar, srcjar)
+
+maven_jar=repository_rule(
+  implementation=_maven_jar_impl,
+  local=True,
+  attrs={
+    "artifact": attr.string(mandatory=True),
+    "sha1": attr.string(mandatory=True),
+    "src_sha1": attr.string(),
+    "_download_script": attr.label(default=Label("//tools:download_file.py")),
+    "repository": attr.string(default=MAVEN_CENTRAL),
+    "attach_source": attr.bool(default=True),
+    "unsign": attr.bool(default=False),
+    "exclude": attr.string_list(),
+  })
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
index 32f57e86..bce8d66 100644
--- a/tools/bzl/plugin.bzl
+++ b/tools/bzl/plugin.bzl
@@ -1,36 +1,81 @@
+load('//tools/bzl:genrule2.bzl', 'genrule2')
+load('//tools/bzl:gwt.bzl', 'GWT_PLUGIN_DEPS',
+     'GWT_PLUGIN_DEPS_NEVERLINK', 'GWT_TRANSITIVE_DEPS',
+     'GWT_COMPILER_ARGS', 'PLUGIN_DEPS_NEVERLINK',
+     'GWT_JVM_ARGS', 'gwt_binary')
 
 def gerrit_plugin(
     name,
     deps = [],
     srcs = [],
+    gwt_module = [],
     resources = [],
     manifest_entries = [],
     **kwargs):
-  # TODO(davido): Fix stamping: run git describe in plugin directory
-  # https://github.com/bazelbuild/bazel/issues/1758
-  manifest_lines = [
-    "Gerrit-ApiType: plugin",
-    "Implementation-Version: 1.0",
-    "Implementation-Vendor: Gerrit Code Review",
-  ]
-  for line in manifest_entries:
-    manifest_lines.append(line.replace('$', '\$'))
-
   native.java_library(
     name = name + '__plugin',
     srcs = srcs,
     resources = resources,
-    deps = deps + ['//gerrit-plugin-api:lib-neverlink'],
+    deps = deps + GWT_PLUGIN_DEPS_NEVERLINK + PLUGIN_DEPS_NEVERLINK,
     visibility = ['//visibility:public'],
   )
 
+  static_jars = []
+  if gwt_module:
+    static_jars = [':%s-static' % name]
+
   native.java_binary(
-    name = name,
-    deploy_manifest_lines = manifest_lines,
+    name = '%s__non_stamped' % name,
+    deploy_manifest_lines = manifest_entries + [
+      "Gerrit-ApiType: plugin",
+      "Implementation-Vendor: Gerrit Code Review",
+    ],
     main_class = 'Dummy',
     runtime_deps = [
       ':%s__plugin' % name,
-    ],
+    ] + static_jars,
     visibility = ['//visibility:public'],
     **kwargs
   )
+
+  if gwt_module:
+    native.java_library(
+      name = name + '__gwt_module',
+      resources = list(set(srcs + resources)),
+      runtime_deps = deps + GWT_PLUGIN_DEPS,
+      visibility = ['//visibility:public'],
+    )
+    genrule2(
+      name = '%s-static' % name,
+      cmd = ' && '.join([
+        'mkdir -p $$TMP/static',
+        'unzip -qd $$TMP/static $(location %s__gwt_application)' % name,
+        'cd $$TMP',
+        'zip -qr $$ROOT/$@ .']),
+      tools = [':%s__gwt_application' % name],
+      outs = ['%s-static.jar' % name],
+    )
+    gwt_binary(
+      name = name + '__gwt_application',
+      module = [gwt_module],
+      deps = GWT_PLUGIN_DEPS + GWT_TRANSITIVE_DEPS + ['//lib/gwt:dev'],
+      module_deps = [':%s__gwt_module' % name],
+      compiler_args = GWT_COMPILER_ARGS,
+      jvm_args = GWT_JVM_ARGS,
+    )
+
+  # TODO(davido): Remove manual merge of manifest file when this feature
+  # request is implemented: https://github.com/bazelbuild/bazel/issues/2009
+  genrule2(
+    name = name,
+    stamp = 1,
+    srcs = ['%s__non_stamped_deploy.jar' % name],
+    cmd = " && ".join([
+      "GEN_VERSION=$$(cat bazel-out/stable-status.txt | grep %s | cut -d ' ' -f 2)" % name.upper(),
+      "cd $$TMP",
+      "unzip -q $$ROOT/$<",
+      "echo \"Implementation-Version: $$GEN_VERSION\n$$(cat META-INF/MANIFEST.MF)\" > META-INF/MANIFEST.MF",
+      "zip -qr $$ROOT/$@ ."]),
+    outs = ['%s.jar' % name],
+    visibility = ['//visibility:public'],
+  )
diff --git a/tools/bzl/plugins.bzl b/tools/bzl/plugins.bzl
new file mode 100644
index 0000000..287a989
--- /dev/null
+++ b/tools/bzl/plugins.bzl
@@ -0,0 +1,12 @@
+CORE_PLUGINS = [
+  'commit-message-length-validator',
+  'download-commands',
+  'hooks',
+  'replication',
+  'reviewnotes',
+  'singleusergroup',
+]
+
+CUSTOM_PLUGINS = [
+  'cookbook-plugin',
+]
diff --git a/tools/bzl/unsign.bzl b/tools/bzl/unsign.bzl
deleted file mode 100644
index f42986a..0000000
--- a/tools/bzl/unsign.bzl
+++ /dev/null
@@ -1,16 +0,0 @@
-
-def unsign_jars(name, deps, **kwargs):
-  """unsign_jars collects its dependencies into a single java_import.
-
-  As a side effect, the signature is removed.
-  """
-  native.java_binary(
-    name = name + '-unsigned-binary',
-    runtime_deps = deps,
-    main_class = 'dummy'
-  )
-
-  native.java_import(
-    name = name,
-    jars = [ name + '-unsigned-binary_deploy.jar' ],
-    **kwargs)
diff --git a/tools/download_file.py b/tools/download_file.py
index c9736bf..c9c6ef0 100755
--- a/tools/download_file.py
+++ b/tools/download_file.py
@@ -103,7 +103,7 @@
 
   print('Download %s' % src_url, file=stderr)
   try:
-    check_call(['curl', '--proxy-anyauth', '-ksfo', cache_ent, src_url])
+    check_call(['curl', '--proxy-anyauth', '-ksSfo', cache_ent, src_url])
   except OSError as err:
     print('could not invoke curl: %s\nis curl installed?' % err, file=stderr)
     exit(1)
diff --git a/tools/eclipse/BUILD b/tools/eclipse/BUILD
new file mode 100644
index 0000000..41c89b1
--- /dev/null
+++ b/tools/eclipse/BUILD
@@ -0,0 +1,68 @@
+load('//tools/bzl:pkg_war.bzl', 'LIBS', 'PGMLIBS')
+load('//tools/bzl:classpath.bzl', 'classpath_collector')
+load('//tools/bzl:plugins.bzl',
+     'CORE_PLUGINS',
+     'CUSTOM_PLUGINS')
+
+PROVIDED_DEPS = [
+  '//lib/bouncycastle:bcprov',
+  '//lib/bouncycastle:bcpg',
+  '//lib/bouncycastle:bcpkix',
+]
+
+TEST_DEPS = [
+  '//gerrit-gpg:gpg_tests',
+  '//gerrit-gwtui:ui_tests',
+  '//gerrit-httpd:httpd_tests',
+  '//gerrit-patch-jgit:jgit_patch_tests',
+  '//gerrit-reviewdb:client_tests',
+  '//gerrit-server:server_tests',
+]
+
+DEPS = [
+  '//gerrit-acceptance-tests:lib',
+  '//gerrit-gwtdebug:gwtdebug',
+  '//gerrit-gwtui:ui_module',
+  '//gerrit-main:main_lib',
+  '//gerrit-plugin-gwtui:gwtui-api-lib',
+  '//gerrit-server:server',
+  '//lib/asciidoctor:asciidoc_lib',
+  '//lib/asciidoctor:doc_indexer_lib',
+  '//lib/auto:auto-value',
+  '//lib/gwt:ant',
+  '//lib/gwt:colt',
+  '//lib/gwt:javax-validation',
+  '//lib/gwt:javax-validation_src',
+  '//lib/gwt:jsinterop-annotations',
+  '//lib/gwt:jsinterop-annotations_src',
+  '//lib/gwt:tapestry',
+  '//lib/gwt:w3c-css-sac',
+  '//lib/jetty:servlets',
+  '//lib/prolog:compiler_lib',
+  # TODO(davido): I do not understand why it must be on the Eclipse classpath
+  #'//Documentation:index',
+]
+
+java_library(
+  name = 'classpath',
+  runtime_deps = LIBS + PGMLIBS + DEPS,
+  testonly = 1,
+)
+
+classpath_collector(
+  name = 'main_classpath_collect',
+  deps = LIBS + PGMLIBS + DEPS + TEST_DEPS + PROVIDED_DEPS +
+    ['//plugins/%s:%s__plugin' % (n, n)
+     for n in CORE_PLUGINS + CUSTOM_PLUGINS],
+  testonly = 1,
+)
+
+classpath_collector(
+  name = "gwt_classpath_collect",
+  deps = ["//gerrit-gwtui:ui_module"],
+)
+
+classpath_collector(
+  name = "autovalue_classpath_collect",
+  deps = ["//lib/auto:auto-value"],
+)
diff --git a/tools/eclipse/gerrit_daemon.launch b/tools/eclipse/gerrit_daemon.launch
index cbc6204..9495884 100644
--- a/tools/eclipse/gerrit_daemon.launch
+++ b/tools/eclipse/gerrit_daemon.launch
@@ -13,5 +13,5 @@
 <stringAttribute key="org.eclipse.jdt.launching.MAIN_TYPE" value="Main"/>
 <stringAttribute key="org.eclipse.jdt.launching.PROGRAM_ARGUMENTS" value="daemon --console-log --show-stack-trace -d ${resource_loc:/gerrit}/../gerrit_testsite"/>
 <stringAttribute key="org.eclipse.jdt.launching.PROJECT_ATTR" value="gerrit"/>
-<stringAttribute key="org.eclipse.jdt.launching.VM_ARGUMENTS" value="-Dgerrit.plugin-classes=${resource_loc:/gerrit/buck-out}/eclipse/plugins"/>
+<stringAttribute key="org.eclipse.jdt.launching.VM_ARGUMENTS" value="-Dgerrit.plugin-classes=${resource_loc:/gerrit/eclipse-out}/plugins"/>
 </launchConfiguration>
diff --git a/tools/eclipse/gerrit_gwt_debug.launch b/tools/eclipse/gerrit_gwt_debug.launch
index b2ab320..9f2bf2b 100644
--- a/tools/eclipse/gerrit_gwt_debug.launch
+++ b/tools/eclipse/gerrit_gwt_debug.launch
@@ -16,7 +16,7 @@
 </listAttribute>
 <booleanAttribute key="org.eclipse.jdt.launching.DEFAULT_CLASSPATH" value="false"/>
 <stringAttribute key="org.eclipse.jdt.launching.MAIN_TYPE" value="com.google.gerrit.gwtdebug.GerritGwtDebugLauncher"/>
-<stringAttribute key="org.eclipse.jdt.launching.PROGRAM_ARGUMENTS" value="-strict -noprecompile -src ${resource_loc:/gerrit} -workDir ${resource_loc:/gerrit}/buck-out/gen/gerrit-gwtui com.google.gerrit.GerritGwtUI -src ${resource_loc:/gerrit}/gerrit-plugin-gwtui/src/main/java -- --console-log --show-stack-trace -d ${resource_loc:/gerrit}/../gerrit_testsite"/>
+<stringAttribute key="org.eclipse.jdt.launching.PROGRAM_ARGUMENTS" value="-strict -noprecompile -src ${resource_loc:/gerrit} -workDir ${resource_loc:/gerrit}/.gwt_work_dir com.google.gerrit.GerritGwtUI -src ${resource_loc:/gerrit}/gerrit-plugin-gwtui/src/main/java -- --console-log --show-stack-trace -d ${resource_loc:/gerrit}/../gerrit_testsite"/>
 <stringAttribute key="org.eclipse.jdt.launching.PROJECT_ATTR" value="gerrit"/>
 <stringAttribute key="org.eclipse.jdt.launching.VM_ARGUMENTS" value="-Xmx1024M&#10;-XX:MaxPermSize=256M&#10;-Dgerrit.disable-gwtui-recompile=true"/>
 </launchConfiguration>
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index 96ddff1..46cd9fc 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -17,7 +17,7 @@
 
 from __future__ import print_function
 from optparse import OptionParser
-from os import path
+from os import makedirs, path
 from subprocess import Popen, PIPE, CalledProcessError, check_call
 from xml.dom import minidom
 import re
@@ -75,6 +75,10 @@
 </projectDescription>\
 """, file=fd)
 
+def gen_primary_build_tool():
+  with open(path.join(ROOT, ".primary_build_tool"), 'w') as fd:
+    fd.write("buck")
+
 def gen_plugin_classpath(root):
   p = path.join(root, '.classpath')
   with open(p, 'w') as fd:
@@ -244,6 +248,12 @@
   gen_project(args.project_name)
   gen_classpath()
   gen_factorypath()
+  gen_primary_build_tool()
+
+  # TODO(davido): Remove this when GWT gone
+  gwt_working_dir = ".gwt_work_dir"
+  if not path.isdir(gwt_working_dir):
+    makedirs(path.join(ROOT, gwt_working_dir))
 
   try:
     targets = ['//tools:buck'] + MAIN + GWT
diff --git a/tools/eclipse/project_bzl.py b/tools/eclipse/project_bzl.py
new file mode 100755
index 0000000..a7ddf6f
--- /dev/null
+++ b/tools/eclipse/project_bzl.py
@@ -0,0 +1,280 @@
+#!/usr/bin/env python
+# Copyright (C) 2016 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# TODO(sop): Remove hack after Buck supports Eclipse
+
+from __future__ import print_function
+# TODO(davido): use Google style for importing instead:
+# import optparse
+# ...
+# optparse.OptionParser
+from optparse import OptionParser
+from os import environ, path, makedirs
+from subprocess import CalledProcessError, check_call, check_output
+from xml.dom import minidom
+import re
+import sys
+
+MAIN = '//tools/eclipse:classpath'
+GWT = '//gerrit-gwtui:ui_module'
+AUTO = '//lib/auto:auto-value'
+JRE = '/'.join([
+  'org.eclipse.jdt.launching.JRE_CONTAINER',
+  'org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType',
+  'JavaSE-1.8',
+])
+# Map of targets to corresponding classpath collector rules
+cp_targets = {
+  AUTO: '//tools/eclipse:autovalue_classpath_collect',
+  GWT: '//tools/eclipse:gwt_classpath_collect',
+  MAIN: '//tools/eclipse:main_classpath_collect',
+}
+
+ROOT = path.abspath(__file__)
+while not path.exists(path.join(ROOT, 'WORKSPACE')):
+  ROOT = path.dirname(ROOT)
+
+opts = OptionParser()
+opts.add_option('--plugins', help='create eclipse projects for plugins',
+                action='store_true')
+opts.add_option('--name', help='name of the generated project',
+                action='store', default='gerrit', dest='project_name')
+args, _ = opts.parse_args()
+
+def retrieve_ext_location():
+  return check_output(['bazel', 'info', 'output_base']).strip()
+
+def gen_primary_build_tool():
+  bazel = check_output(['which', 'bazel']).strip()
+  with open(path.join(ROOT, ".primary_build_tool"), 'w') as fd:
+    fd.write("bazel=%s\n" % bazel)
+    fd.write("PATH=%s\n" % environ["PATH"])
+
+def _query_classpath(target):
+  deps = []
+  t = cp_targets[target]
+  try:
+    check_call(['bazel', 'build', t])
+  except CalledProcessError:
+    exit(1)
+  name = 'bazel-bin/tools/eclipse/' + t.split(':')[1] + '.runtime_classpath'
+  deps = [line.rstrip('\n') for line in open(name)]
+  return deps
+
+def gen_project(name='gerrit', root=ROOT):
+  p = path.join(root, '.project')
+  with open(p, 'w') as fd:
+    print("""\
+<?xml version="1.0" encoding="UTF-8"?>
+<projectDescription>
+  <name>%(name)s</name>
+  <buildSpec>
+    <buildCommand>
+      <name>org.eclipse.jdt.core.javabuilder</name>
+    </buildCommand>
+  </buildSpec>
+  <natures>
+    <nature>org.eclipse.jdt.core.javanature</nature>
+  </natures>
+</projectDescription>\
+    """ % {"name": name}, file=fd)
+
+def gen_plugin_classpath(root):
+  p = path.join(root, '.classpath')
+  with open(p, 'w') as fd:
+    if path.exists(path.join(root, 'src', 'test', 'java')):
+      testpath = """
+  <classpathentry excluding="**/BUILD" kind="src" path="src/test/java"\
+ out="eclipse-out/test"/>"""
+    else:
+      testpath = ""
+    print("""\
+<?xml version="1.0" encoding="UTF-8"?>
+<classpath>
+  <classpathentry excluding="**/BUILD" kind="src" path="src/main/java"/>%(testpath)s
+  <classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER"/>
+  <classpathentry combineaccessrules="false" kind="src" path="/gerrit"/>
+  <classpathentry kind="output" path="eclipse-out/classes"/>
+</classpath>""" % {"testpath": testpath}, file=fd)
+
+def gen_classpath(ext):
+  def make_classpath():
+    impl = minidom.getDOMImplementation()
+    return impl.createDocument(None, 'classpath', None)
+
+  def classpathentry(kind, path, src=None, out=None, exported=None):
+    e = doc.createElement('classpathentry')
+    e.setAttribute('kind', kind)
+    # TODO(davido): Remove this and other exclude BUILD files hack
+    # when this Bazel bug is fixed:
+    # https://github.com/bazelbuild/bazel/issues/1083
+    if kind == 'src':
+      e.setAttribute('excluding', '**/BUILD')
+    e.setAttribute('path', path)
+    if src:
+      e.setAttribute('sourcepath', src)
+    if out:
+      e.setAttribute('output', out)
+    if exported:
+      e.setAttribute('exported', 'true')
+    doc.documentElement.appendChild(e)
+
+  doc = make_classpath()
+  src = set()
+  lib = set()
+  gwt_src = set()
+  gwt_lib = set()
+  plugins = set()
+
+  # Classpath entries are absolute for cross-cell support
+  java_library = re.compile('bazel-out/local-fastbuild/bin/(.*)/[^/]+[.]jar$')
+  srcs = re.compile('(.*/external/[^/]+)/jar/(.*)[.]jar')
+  for p in _query_classpath(MAIN):
+    if p.endswith('-src.jar'):
+      # gwt_module() depends on -src.jar for Java to JavaScript compiles.
+      if p.startswith("external"):
+        p = path.join(ext, p)
+      gwt_lib.add(p)
+      continue
+
+
+    m = java_library.match(p)
+    if m:
+      src.add(m.group(1))
+      # Exceptions: both source and lib
+      if p.endswith('libquery_parser.jar') or \
+         p.endswith('prolog/libcommon.jar'):
+        lib.add(p)
+    else:
+      # Don't mess up with Bazel internal test runner dependencies.
+      # When we use Eclipse we rely on it for running the tests
+      if p.endswith("external/bazel_tools/tools/jdk/TestRunner_deploy.jar"):
+        continue
+      if p.startswith("external"):
+        p = path.join(ext, p)
+      lib.add(p)
+
+  for p in _query_classpath(GWT):
+    m = java_library.match(p)
+    if m:
+      gwt_src.add(m.group(1))
+
+  for s in sorted(src):
+    out = None
+
+    if s.startswith('lib/'):
+      out = 'eclipse-out/lib'
+    elif s.startswith('plugins/'):
+      if args.plugins:
+        plugins.add(s)
+        continue
+      out = 'eclipse-out/' + s
+
+    p = path.join(s, 'java')
+    if path.exists(p):
+      classpathentry('src', p, out=out)
+      continue
+
+    for env in ['main', 'test']:
+      o = None
+      if out:
+        o = out + '/' + env
+      elif env == 'test':
+        o = 'eclipse-out/test'
+
+      for srctype in ['java', 'resources']:
+        p = path.join(s, 'src', env, srctype)
+        if path.exists(p):
+          classpathentry('src', p, out=o)
+
+  for libs in [lib, gwt_lib]:
+    for j in sorted(libs):
+      s = None
+      m = srcs.match(j)
+      if m:
+        prefix = m.group(1)
+        suffix = m.group(2)
+        p = path.join(prefix, "jar", "%s-src.jar" % suffix)
+        if path.exists(p):
+          s = p
+      if args.plugins:
+        classpathentry('lib', j, s, exported=True)
+      else:
+        # Filter out the source JARs that we pull through transitive closure of
+        # GWT plugin API (we add source directories themself).  Exception is
+        # libEdit-src.jar, that is needed for GWT SDM to work.
+        m = java_library.match(j)
+        if m:
+          if m.group(1).startswith("gerrit-") and \
+              j.endswith("-src.jar") and \
+              not j.endswith("libEdit-src.jar"):
+            continue
+        classpathentry('lib', j, s)
+
+  for s in sorted(gwt_src):
+    p = path.join(ROOT, s, 'src', 'main', 'java')
+    if path.exists(p):
+      classpathentry('lib', p, out='eclipse-out/gwtsrc')
+
+  classpathentry('con', JRE)
+  classpathentry('output', 'eclipse-out/classes')
+
+  p = path.join(ROOT, '.classpath')
+  with open(p, 'w') as fd:
+    doc.writexml(fd, addindent='\t', newl='\n', encoding='UTF-8')
+
+  if args.plugins:
+    for plugin in plugins:
+      plugindir = path.join(ROOT, plugin)
+      try:
+        gen_project(plugin.replace('plugins/', ""), plugindir)
+        gen_plugin_classpath(plugindir)
+      except (IOError, OSError) as err:
+        print('error generating project for %s: %s' % (plugin, err),
+              file=sys.stderr)
+
+def gen_factorypath(ext):
+  doc = minidom.getDOMImplementation().createDocument(None, 'factorypath', None)
+  for jar in _query_classpath(AUTO):
+    e = doc.createElement('factorypathentry')
+    e.setAttribute('kind', 'EXTJAR')
+    e.setAttribute('id', path.join(ext, jar))
+    e.setAttribute('enabled', 'true')
+    e.setAttribute('runInBatchMode', 'false')
+    doc.documentElement.appendChild(e)
+
+  p = path.join(ROOT, '.factorypath')
+  with open(p, 'w') as fd:
+    doc.writexml(fd, addindent='\t', newl='\n', encoding='UTF-8')
+
+try:
+  ext_location = retrieve_ext_location()
+  gen_project(args.project_name)
+  gen_classpath(ext_location)
+  gen_factorypath(ext_location)
+  gen_primary_build_tool()
+
+  # TODO(davido): Remove this when GWT gone
+  gwt_working_dir = ".gwt_work_dir"
+  if not path.isdir(gwt_working_dir):
+    makedirs(path.join(ROOT, gwt_working_dir))
+
+  try:
+    check_call(['bazel', 'build', MAIN, GWT, '//gerrit-patch-jgit:libEdit-src.jar'])
+  except CalledProcessError:
+    exit(1)
+except KeyboardInterrupt:
+  print('Interrupted by user', file=sys.stderr)
+  exit(1)
diff --git a/tools/intellij/Gerrit_Code_Style.xml b/tools/intellij/Gerrit_Code_Style.xml
new file mode 100644
index 0000000..b913e09
--- /dev/null
+++ b/tools/intellij/Gerrit_Code_Style.xml
@@ -0,0 +1,531 @@
+<code_scheme name="Google Format (Gerrit)">
+  <option name="OTHER_INDENT_OPTIONS">
+    <value>
+      <option name="INDENT_SIZE" value="2" />
+      <option name="CONTINUATION_INDENT_SIZE" value="4" />
+      <option name="TAB_SIZE" value="2" />
+      <option name="USE_TAB_CHARACTER" value="false" />
+      <option name="SMART_TABS" value="false" />
+      <option name="LABEL_INDENT_SIZE" value="0" />
+      <option name="LABEL_INDENT_ABSOLUTE" value="false" />
+      <option name="USE_RELATIVE_INDENTS" value="false" />
+    </value>
+  </option>
+  <option name="CLASS_COUNT_TO_USE_IMPORT_ON_DEMAND" value="999" />
+  <option name="NAMES_COUNT_TO_USE_IMPORT_ON_DEMAND" value="999" />
+  <option name="PACKAGES_TO_USE_IMPORT_ON_DEMAND">
+    <value />
+  </option>
+  <option name="IMPORT_LAYOUT_TABLE">
+    <value>
+      <package name="" withSubpackages="true" static="true" />
+      <emptyLine />
+      <package name="com.google" withSubpackages="true" static="false" />
+      <emptyLine />
+      <package name="org" withSubpackages="true" static="false" />
+      <emptyLine />
+      <package name="java" withSubpackages="true" static="false" />
+      <emptyLine />
+      <package name="" withSubpackages="true" static="false" />
+    </value>
+  </option>
+  <option name="RIGHT_MARGIN" value="80" />
+  <option name="JD_ALIGN_PARAM_COMMENTS" value="false" />
+  <option name="JD_ALIGN_EXCEPTION_COMMENTS" value="false" />
+  <option name="JD_P_AT_EMPTY_LINES" value="false" />
+  <option name="JD_KEEP_EMPTY_PARAMETER" value="false" />
+  <option name="JD_KEEP_EMPTY_EXCEPTION" value="false" />
+  <option name="JD_KEEP_EMPTY_RETURN" value="false" />
+  <option name="KEEP_CONTROL_STATEMENT_IN_ONE_LINE" value="false" />
+  <option name="KEEP_BLANK_LINES_IN_CODE" value="1" />
+  <option name="BLANK_LINES_AFTER_CLASS_HEADER" value="1" />
+  <option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
+  <option name="ALIGN_MULTILINE_FOR" value="false" />
+  <option name="CALL_PARAMETERS_WRAP" value="1" />
+  <option name="METHOD_PARAMETERS_WRAP" value="1" />
+  <option name="EXTENDS_LIST_WRAP" value="1" />
+  <option name="THROWS_KEYWORD_WRAP" value="1" />
+  <option name="METHOD_CALL_CHAIN_WRAP" value="1" />
+  <option name="BINARY_OPERATION_WRAP" value="1" />
+  <option name="BINARY_OPERATION_SIGN_ON_NEXT_LINE" value="true" />
+  <option name="TERNARY_OPERATION_WRAP" value="1" />
+  <option name="TERNARY_OPERATION_SIGNS_ON_NEXT_LINE" value="true" />
+  <option name="FOR_STATEMENT_WRAP" value="1" />
+  <option name="ARRAY_INITIALIZER_WRAP" value="1" />
+  <option name="WRAP_COMMENTS" value="true" />
+  <option name="IF_BRACE_FORCE" value="3" />
+  <option name="DOWHILE_BRACE_FORCE" value="3" />
+  <option name="WHILE_BRACE_FORCE" value="3" />
+  <option name="FOR_BRACE_FORCE" value="3" />
+  <AndroidXmlCodeStyleSettings>
+    <option name="USE_CUSTOM_SETTINGS" value="true" />
+    <option name="LAYOUT_SETTINGS">
+      <value>
+        <option name="INSERT_BLANK_LINE_BEFORE_TAG" value="false" />
+      </value>
+    </option>
+  </AndroidXmlCodeStyleSettings>
+  <JSCodeStyleSettings>
+    <option name="INDENT_CHAINED_CALLS" value="false" />
+  </JSCodeStyleSettings>
+  <Python>
+    <option name="USE_CONTINUATION_INDENT_FOR_ARGUMENTS" value="true" />
+  </Python>
+  <TypeScriptCodeStyleSettings>
+    <option name="INDENT_CHAINED_CALLS" value="false" />
+  </TypeScriptCodeStyleSettings>
+  <XML>
+    <option name="XML_ALIGN_ATTRIBUTES" value="false" />
+    <option name="XML_LEGACY_SETTINGS_IMPORTED" value="true" />
+  </XML>
+  <codeStyleSettings language="CSS">
+    <indentOptions>
+      <option name="INDENT_SIZE" value="2" />
+      <option name="CONTINUATION_INDENT_SIZE" value="4" />
+      <option name="TAB_SIZE" value="2" />
+    </indentOptions>
+  </codeStyleSettings>
+  <codeStyleSettings language="ECMA Script Level 4">
+    <option name="KEEP_BLANK_LINES_IN_CODE" value="1" />
+    <option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
+    <option name="ALIGN_MULTILINE_FOR" value="false" />
+    <option name="CALL_PARAMETERS_WRAP" value="1" />
+    <option name="METHOD_PARAMETERS_WRAP" value="1" />
+    <option name="EXTENDS_LIST_WRAP" value="1" />
+    <option name="BINARY_OPERATION_WRAP" value="1" />
+    <option name="BINARY_OPERATION_SIGN_ON_NEXT_LINE" value="true" />
+    <option name="TERNARY_OPERATION_WRAP" value="1" />
+    <option name="TERNARY_OPERATION_SIGNS_ON_NEXT_LINE" value="true" />
+    <option name="FOR_STATEMENT_WRAP" value="1" />
+    <option name="ARRAY_INITIALIZER_WRAP" value="1" />
+    <option name="IF_BRACE_FORCE" value="3" />
+    <option name="DOWHILE_BRACE_FORCE" value="3" />
+    <option name="WHILE_BRACE_FORCE" value="3" />
+    <option name="FOR_BRACE_FORCE" value="3" />
+    <option name="PARENT_SETTINGS_INSTALLED" value="true" />
+  </codeStyleSettings>
+  <codeStyleSettings language="HTML">
+    <indentOptions>
+      <option name="INDENT_SIZE" value="2" />
+      <option name="CONTINUATION_INDENT_SIZE" value="4" />
+      <option name="TAB_SIZE" value="2" />
+    </indentOptions>
+  </codeStyleSettings>
+  <codeStyleSettings language="JAVA">
+    <option name="KEEP_FIRST_COLUMN_COMMENT" value="false" />
+    <option name="KEEP_CONTROL_STATEMENT_IN_ONE_LINE" value="false" />
+    <option name="KEEP_BLANK_LINES_IN_DECLARATIONS" value="3" />
+    <option name="KEEP_BLANK_LINES_IN_CODE" value="3" />
+    <option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="3" />
+    <option name="BLANK_LINES_BEFORE_IMPORTS" value="0" />
+    <option name="BLANK_LINES_AROUND_CLASS" value="2" />
+    <option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
+    <option name="ALIGN_MULTILINE_RESOURCES" value="false" />
+    <option name="ALIGN_MULTILINE_FOR" value="false" />
+    <option name="SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE" value="true" />
+    <option name="CALL_PARAMETERS_WRAP" value="1" />
+    <option name="METHOD_PARAMETERS_WRAP" value="1" />
+    <option name="EXTENDS_LIST_WRAP" value="1" />
+    <option name="THROWS_LIST_WRAP" value="1" />
+    <option name="EXTENDS_KEYWORD_WRAP" value="1" />
+    <option name="THROWS_KEYWORD_WRAP" value="1" />
+    <option name="METHOD_CALL_CHAIN_WRAP" value="1" />
+    <option name="BINARY_OPERATION_WRAP" value="1" />
+    <option name="BINARY_OPERATION_SIGN_ON_NEXT_LINE" value="true" />
+    <option name="TERNARY_OPERATION_WRAP" value="1" />
+    <option name="TERNARY_OPERATION_SIGNS_ON_NEXT_LINE" value="true" />
+    <option name="KEEP_SIMPLE_BLOCKS_IN_ONE_LINE" value="true" />
+    <option name="FOR_STATEMENT_WRAP" value="1" />
+    <option name="ARRAY_INITIALIZER_WRAP" value="1" />
+    <option name="ASSIGNMENT_WRAP" value="1" />
+    <option name="IF_BRACE_FORCE" value="3" />
+    <option name="DOWHILE_BRACE_FORCE" value="3" />
+    <option name="WHILE_BRACE_FORCE" value="3" />
+    <option name="FOR_BRACE_FORCE" value="3" />
+    <option name="PARENT_SETTINGS_INSTALLED" value="true" />
+    <indentOptions>
+      <option name="CONTINUATION_INDENT_SIZE" value="4" />
+      <option name="TAB_SIZE" value="2" />
+    </indentOptions>
+  </codeStyleSettings>
+  <codeStyleSettings language="JSON">
+    <indentOptions>
+      <option name="CONTINUATION_INDENT_SIZE" value="4" />
+      <option name="TAB_SIZE" value="2" />
+    </indentOptions>
+  </codeStyleSettings>
+  <codeStyleSettings language="JavaScript">
+    <option name="RIGHT_MARGIN" value="80" />
+    <option name="KEEP_BLANK_LINES_IN_CODE" value="1" />
+    <option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
+    <option name="ALIGN_MULTILINE_FOR" value="false" />
+    <option name="CALL_PARAMETERS_WRAP" value="1" />
+    <option name="METHOD_PARAMETERS_WRAP" value="1" />
+    <option name="BINARY_OPERATION_WRAP" value="1" />
+    <option name="BINARY_OPERATION_SIGN_ON_NEXT_LINE" value="true" />
+    <option name="TERNARY_OPERATION_WRAP" value="1" />
+    <option name="TERNARY_OPERATION_SIGNS_ON_NEXT_LINE" value="true" />
+    <option name="FOR_STATEMENT_WRAP" value="1" />
+    <option name="ARRAY_INITIALIZER_WRAP" value="1" />
+    <option name="IF_BRACE_FORCE" value="3" />
+    <option name="DOWHILE_BRACE_FORCE" value="3" />
+    <option name="WHILE_BRACE_FORCE" value="3" />
+    <option name="FOR_BRACE_FORCE" value="3" />
+    <option name="PARENT_SETTINGS_INSTALLED" value="true" />
+    <indentOptions>
+      <option name="INDENT_SIZE" value="2" />
+      <option name="TAB_SIZE" value="2" />
+    </indentOptions>
+  </codeStyleSettings>
+  <codeStyleSettings language="Python">
+    <option name="RIGHT_MARGIN" value="80" />
+    <option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
+    <option name="PARENT_SETTINGS_INSTALLED" value="true" />
+    <indentOptions>
+      <option name="INDENT_SIZE" value="2" />
+      <option name="CONTINUATION_INDENT_SIZE" value="4" />
+      <option name="TAB_SIZE" value="2" />
+    </indentOptions>
+  </codeStyleSettings>
+  <codeStyleSettings language="SASS">
+    <indentOptions>
+      <option name="CONTINUATION_INDENT_SIZE" value="4" />
+      <option name="TAB_SIZE" value="2" />
+    </indentOptions>
+  </codeStyleSettings>
+  <codeStyleSettings language="SCSS">
+    <indentOptions>
+      <option name="CONTINUATION_INDENT_SIZE" value="4" />
+      <option name="TAB_SIZE" value="2" />
+    </indentOptions>
+  </codeStyleSettings>
+  <codeStyleSettings language="TypeScript">
+    <indentOptions>
+      <option name="INDENT_SIZE" value="2" />
+      <option name="TAB_SIZE" value="2" />
+    </indentOptions>
+  </codeStyleSettings>
+  <codeStyleSettings language="XML">
+    <indentOptions>
+      <option name="INDENT_SIZE" value="2" />
+      <option name="CONTINUATION_INDENT_SIZE" value="2" />
+      <option name="TAB_SIZE" value="2" />
+    </indentOptions>
+    <arrangement>
+      <rules>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>xmlns:android</NAME>
+                <XML_ATTRIBUTE />
+                <XML_NAMESPACE>^$</XML_NAMESPACE>
+              </AND>
+            </match>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>xmlns:.*</NAME>
+                <XML_ATTRIBUTE />
+                <XML_NAMESPACE>^$</XML_NAMESPACE>
+              </AND>
+            </match>
+            <order>BY_NAME</order>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>.*:id</NAME>
+                <XML_ATTRIBUTE />
+                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
+              </AND>
+            </match>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>style</NAME>
+                <XML_ATTRIBUTE />
+                <XML_NAMESPACE>^$</XML_NAMESPACE>
+              </AND>
+            </match>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>.*</NAME>
+                <XML_ATTRIBUTE />
+                <XML_NAMESPACE>^$</XML_NAMESPACE>
+              </AND>
+            </match>
+            <order>BY_NAME</order>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>.*:.*Style</NAME>
+                <XML_ATTRIBUTE />
+                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
+              </AND>
+            </match>
+            <order>BY_NAME</order>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>.*:layout_width</NAME>
+                <XML_ATTRIBUTE />
+                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
+              </AND>
+            </match>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>.*:layout_height</NAME>
+                <XML_ATTRIBUTE />
+                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
+              </AND>
+            </match>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>.*:layout_weight</NAME>
+                <XML_ATTRIBUTE />
+                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
+              </AND>
+            </match>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>.*:layout_margin</NAME>
+                <XML_ATTRIBUTE />
+                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
+              </AND>
+            </match>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>.*:layout_marginTop</NAME>
+                <XML_ATTRIBUTE />
+                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
+              </AND>
+            </match>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>.*:layout_marginBottom</NAME>
+                <XML_ATTRIBUTE />
+                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
+              </AND>
+            </match>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>.*:layout_marginStart</NAME>
+                <XML_ATTRIBUTE />
+                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
+              </AND>
+            </match>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>.*:layout_marginEnd</NAME>
+                <XML_ATTRIBUTE />
+                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
+              </AND>
+            </match>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>.*:layout_marginLeft</NAME>
+                <XML_ATTRIBUTE />
+                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
+              </AND>
+            </match>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>.*:layout_marginRight</NAME>
+                <XML_ATTRIBUTE />
+                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
+              </AND>
+            </match>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>.*:layout_.*</NAME>
+                <XML_ATTRIBUTE />
+                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
+              </AND>
+            </match>
+            <order>BY_NAME</order>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>.*:padding</NAME>
+                <XML_ATTRIBUTE />
+                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
+              </AND>
+            </match>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>.*:paddingTop</NAME>
+                <XML_ATTRIBUTE />
+                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
+              </AND>
+            </match>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>.*:paddingBottom</NAME>
+                <XML_ATTRIBUTE />
+                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
+              </AND>
+            </match>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>.*:paddingStart</NAME>
+                <XML_ATTRIBUTE />
+                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
+              </AND>
+            </match>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>.*:paddingEnd</NAME>
+                <XML_ATTRIBUTE />
+                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
+              </AND>
+            </match>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>.*:paddingLeft</NAME>
+                <XML_ATTRIBUTE />
+                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
+              </AND>
+            </match>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>.*:paddingRight</NAME>
+                <XML_ATTRIBUTE />
+                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
+              </AND>
+            </match>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>.*</NAME>
+                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
+              </AND>
+            </match>
+            <order>BY_NAME</order>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>.*</NAME>
+                <XML_NAMESPACE>http://schemas.android.com/apk/res-auto</XML_NAMESPACE>
+              </AND>
+            </match>
+            <order>BY_NAME</order>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>.*</NAME>
+                <XML_NAMESPACE>http://schemas.android.com/tools</XML_NAMESPACE>
+              </AND>
+            </match>
+            <order>BY_NAME</order>
+          </rule>
+        </section>
+        <section>
+          <rule>
+            <match>
+              <AND>
+                <NAME>.*</NAME>
+                <XML_NAMESPACE>.*</XML_NAMESPACE>
+              </AND>
+            </match>
+            <order>BY_NAME</order>
+          </rule>
+        </section>
+      </rules>
+    </arrangement>
+  </codeStyleSettings>
+</code_scheme>
diff --git a/tools/intellij/copyright/Gerrit_Copyright.xml b/tools/intellij/copyright/Gerrit_Copyright.xml
new file mode 100644
index 0000000..5609cdc
--- /dev/null
+++ b/tools/intellij/copyright/Gerrit_Copyright.xml
@@ -0,0 +1,6 @@
+<component name="CopyrightManager">
+  <copyright>
+    <option name="myName" value="Gerrit Copyright" />
+    <option name="notice" value="Copyright (C) &amp;#36;today.year The Android Open Source Project&#10;&#10;Licensed under the Apache License, Version 2.0 (the &quot;License&quot;);&#10;you may not use this file except in compliance with the License.&#10;You may obtain a copy of the License at&#10;&#10;http://www.apache.org/licenses/LICENSE-2.0&#10;&#10;Unless required by applicable law or agreed to in writing, software&#10;distributed under the License is distributed on an &quot;AS IS&quot; BASIS,&#10;WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.&#10;See the License for the specific language governing permissions and&#10;limitations under the License." />
+  </copyright>
+</component>
\ No newline at end of file
diff --git a/tools/intellij/copyright/profiles_settings.xml b/tools/intellij/copyright/profiles_settings.xml
new file mode 100644
index 0000000..dfb94d5
--- /dev/null
+++ b/tools/intellij/copyright/profiles_settings.xml
@@ -0,0 +1,7 @@
+<component name="CopyrightManager">
+  <settings default="Gerrit Copyright">
+    <LanguageOptions name="__TEMPLATE__">
+      <option name="block" value="false" />
+    </LanguageOptions>
+  </settings>
+</component>
\ No newline at end of file
diff --git a/tools/intellij/gerrit_daemon.xml b/tools/intellij/gerrit_daemon.xml
new file mode 100644
index 0000000..85dc6a7
--- /dev/null
+++ b/tools/intellij/gerrit_daemon.xml
@@ -0,0 +1,16 @@
+<component name="ProjectRunConfigurationManager">
+  <configuration default="false" name="gerrit_daemon" type="Application" factoryName="Application" singleton="true">
+    <extension name="coverage" enabled="false" merge="false" sample_coverage="true" runner="idea" />
+    <option name="MAIN_CLASS_NAME" value="Main" />
+    <option name="PROGRAM_PARAMETERS" value="daemon --console-log --show-stack-trace -d ${GERRIT_TESTSITE}" />
+    <option name="WORKING_DIRECTORY" value="file://$MODULE_DIR$" />
+    <option name="ALTERNATIVE_JRE_PATH_ENABLED" value="false" />
+    <option name="ALTERNATIVE_JRE_PATH" />
+    <option name="ENABLE_SWING_INSPECTOR" value="false" />
+    <option name="ENV_VARIABLES" />
+    <option name="PASS_PARENT_ENVS" value="true" />
+    <module name=".workspace" />
+    <envs />
+    <method />
+  </configuration>
+</component>
diff --git a/tools/workspace-status.sh b/tools/workspace-status.sh
index cbc263e..cd48a30 100755
--- a/tools/workspace-status.sh
+++ b/tools/workspace-status.sh
@@ -19,3 +19,4 @@
   test -d "$p" || continue
   echo STABLE_BUILD_$(echo $(basename $p)_LABEL|tr [a-z] [A-Z]) $(rev $p)
 done
+echo "STABLE_WORKSPACE_ROOT ${PWD}"